astro 5.0.9 → 5.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.d.ts +10 -0
- package/dist/actions/plugins.js +6 -6
- package/dist/assets/build/generate.js +52 -20
- package/dist/assets/build/remote.d.ts +22 -0
- package/dist/assets/build/remote.js +37 -2
- package/dist/config/index.d.ts +2 -2
- package/dist/content/content-layer.js +22 -6
- package/dist/content/loaders/glob.js +1 -1
- package/dist/content/utils.d.ts +1 -0
- package/dist/content/utils.js +23 -1
- package/dist/core/app/index.js +9 -0
- package/dist/core/app/types.d.ts +2 -1
- package/dist/core/build/plugins/plugin-manifest.js +8 -2
- package/dist/core/build/static-build.js +2 -2
- package/dist/core/config/schema.d.ts +312 -0
- package/dist/core/config/schema.js +21 -0
- package/dist/core/constants.js +1 -1
- package/dist/core/dev/dev.js +1 -1
- package/dist/core/errors/errors-data.d.ts +26 -0
- package/dist/core/errors/errors-data.js +14 -0
- package/dist/core/messages.js +2 -2
- package/dist/core/render-context.d.ts +2 -0
- package/dist/core/render-context.js +8 -4
- package/dist/core/session.d.ts +48 -0
- package/dist/core/session.js +385 -0
- package/dist/i18n/middleware.js +4 -4
- package/dist/types/public/common.d.ts +1 -1
- package/dist/types/public/config.d.ts +83 -3
- package/dist/types/public/context.d.ts +5 -0
- package/dist/vite-plugin-astro-server/plugin.js +2 -1
- package/dist/vite-plugin-astro-server/route.js +3 -0
- package/package.json +5 -4
- package/templates/actions.mjs +24 -12
- package/types/actions.d.ts +4 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { stringify, unflatten } from "devalue";
|
|
2
|
+
import {
|
|
3
|
+
builtinDrivers,
|
|
4
|
+
createStorage
|
|
5
|
+
} from "unstorage";
|
|
6
|
+
import { SessionStorageInitError, SessionStorageSaveError } from "./errors/errors-data.js";
|
|
7
|
+
import { AstroError } from "./errors/index.js";
|
|
8
|
+
const PERSIST_SYMBOL = Symbol();
|
|
9
|
+
const DEFAULT_COOKIE_NAME = "astro-session";
|
|
10
|
+
const VALID_COOKIE_REGEX = /^[\w-]+$/;
|
|
11
|
+
class AstroSession {
|
|
12
|
+
// The cookies object.
|
|
13
|
+
#cookies;
|
|
14
|
+
// The session configuration.
|
|
15
|
+
#config;
|
|
16
|
+
// The cookie config
|
|
17
|
+
#cookieConfig;
|
|
18
|
+
// The cookie name
|
|
19
|
+
#cookieName;
|
|
20
|
+
// The unstorage object for the session driver.
|
|
21
|
+
#storage;
|
|
22
|
+
#data;
|
|
23
|
+
// The session ID. A v4 UUID.
|
|
24
|
+
#sessionID;
|
|
25
|
+
// Sessions to destroy. Needed because we won't have the old session ID after it's destroyed locally.
|
|
26
|
+
#toDestroy = /* @__PURE__ */ new Set();
|
|
27
|
+
// Session keys to delete. Used for partial data sets to avoid overwriting the deleted value.
|
|
28
|
+
#toDelete = /* @__PURE__ */ new Set();
|
|
29
|
+
// Whether the session is dirty and needs to be saved.
|
|
30
|
+
#dirty = false;
|
|
31
|
+
// Whether the session cookie has been set.
|
|
32
|
+
#cookieSet = false;
|
|
33
|
+
// The local data is "partial" if it has not been loaded from storage yet and only
|
|
34
|
+
// contains values that have been set or deleted in-memory locally.
|
|
35
|
+
// We do this to avoid the need to block on loading data when it is only being set.
|
|
36
|
+
// When we load the data from storage, we need to merge it with the local partial data,
|
|
37
|
+
// preserving in-memory changes and deletions.
|
|
38
|
+
#partial = true;
|
|
39
|
+
constructor(cookies, {
|
|
40
|
+
cookie: cookieConfig = DEFAULT_COOKIE_NAME,
|
|
41
|
+
...config
|
|
42
|
+
}) {
|
|
43
|
+
this.#cookies = cookies;
|
|
44
|
+
if (typeof cookieConfig === "object") {
|
|
45
|
+
this.#cookieConfig = cookieConfig;
|
|
46
|
+
this.#cookieName = cookieConfig.name || DEFAULT_COOKIE_NAME;
|
|
47
|
+
} else {
|
|
48
|
+
this.#cookieName = cookieConfig || DEFAULT_COOKIE_NAME;
|
|
49
|
+
}
|
|
50
|
+
this.#config = config;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Gets a session value. Returns `undefined` if the session or value does not exist.
|
|
54
|
+
*/
|
|
55
|
+
async get(key) {
|
|
56
|
+
return (await this.#ensureData()).get(key)?.data;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Checks if a session value exists.
|
|
60
|
+
*/
|
|
61
|
+
async has(key) {
|
|
62
|
+
return (await this.#ensureData()).has(key);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Gets all session values.
|
|
66
|
+
*/
|
|
67
|
+
async keys() {
|
|
68
|
+
return (await this.#ensureData()).keys();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Gets all session values.
|
|
72
|
+
*/
|
|
73
|
+
async values() {
|
|
74
|
+
return [...(await this.#ensureData()).values()].map((entry) => entry.data);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Gets all session entries.
|
|
78
|
+
*/
|
|
79
|
+
async entries() {
|
|
80
|
+
return [...(await this.#ensureData()).entries()].map(([key, entry]) => [key, entry.data]);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Deletes a session value.
|
|
84
|
+
*/
|
|
85
|
+
delete(key) {
|
|
86
|
+
this.#data?.delete(key);
|
|
87
|
+
if (this.#partial) {
|
|
88
|
+
this.#toDelete.add(key);
|
|
89
|
+
}
|
|
90
|
+
this.#dirty = true;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Sets a session value. The session is created if it does not exist.
|
|
94
|
+
*/
|
|
95
|
+
set(key, value, { ttl } = {}) {
|
|
96
|
+
if (!key) {
|
|
97
|
+
throw new AstroError({
|
|
98
|
+
...SessionStorageSaveError,
|
|
99
|
+
message: "The session key was not provided."
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
stringify(value);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
throw new AstroError(
|
|
106
|
+
{
|
|
107
|
+
...SessionStorageSaveError,
|
|
108
|
+
message: `The session data for ${key} could not be serialized.`,
|
|
109
|
+
hint: "See the devalue library for all supported types: https://github.com/rich-harris/devalue"
|
|
110
|
+
},
|
|
111
|
+
{ cause: err }
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (!this.#cookieSet) {
|
|
115
|
+
this.#setCookie();
|
|
116
|
+
this.#cookieSet = true;
|
|
117
|
+
}
|
|
118
|
+
this.#data ??= /* @__PURE__ */ new Map();
|
|
119
|
+
const lifetime = ttl ?? this.#config.ttl;
|
|
120
|
+
const expires = typeof lifetime === "number" ? Date.now() + lifetime * 1e3 : lifetime;
|
|
121
|
+
this.#data.set(key, {
|
|
122
|
+
data: value,
|
|
123
|
+
expires
|
|
124
|
+
});
|
|
125
|
+
this.#dirty = true;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Destroys the session, clearing the cookie and storage if it exists.
|
|
129
|
+
*/
|
|
130
|
+
destroy() {
|
|
131
|
+
this.#destroySafe();
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Regenerates the session, creating a new session ID. The existing session data is preserved.
|
|
135
|
+
*/
|
|
136
|
+
async regenerate() {
|
|
137
|
+
let data = /* @__PURE__ */ new Map();
|
|
138
|
+
try {
|
|
139
|
+
data = await this.#ensureData();
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error("Failed to load session data during regeneration:", err);
|
|
142
|
+
}
|
|
143
|
+
const oldSessionId = this.#sessionID;
|
|
144
|
+
this.#sessionID = void 0;
|
|
145
|
+
this.#data = data;
|
|
146
|
+
this.#ensureSessionID();
|
|
147
|
+
await this.#setCookie();
|
|
148
|
+
if (oldSessionId && this.#storage) {
|
|
149
|
+
this.#storage.removeItem(oldSessionId).catch((err) => {
|
|
150
|
+
console.error("Failed to remove old session data:", err);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Persists the session data to storage.
|
|
155
|
+
// This is called automatically at the end of the request.
|
|
156
|
+
// Uses a symbol to prevent users from calling it directly.
|
|
157
|
+
async [PERSIST_SYMBOL]() {
|
|
158
|
+
if (!this.#dirty && !this.#toDestroy.size) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const storage = await this.#ensureStorage();
|
|
162
|
+
if (this.#dirty && this.#data) {
|
|
163
|
+
const data = await this.#ensureData();
|
|
164
|
+
this.#toDelete.forEach((key2) => data.delete(key2));
|
|
165
|
+
const key = this.#ensureSessionID();
|
|
166
|
+
let serialized;
|
|
167
|
+
try {
|
|
168
|
+
serialized = stringify(data, {
|
|
169
|
+
// Support URL objects
|
|
170
|
+
URL: (val) => val instanceof URL && val.href
|
|
171
|
+
});
|
|
172
|
+
} catch (err) {
|
|
173
|
+
throw new AstroError(
|
|
174
|
+
{
|
|
175
|
+
...SessionStorageSaveError,
|
|
176
|
+
message: SessionStorageSaveError.message(
|
|
177
|
+
"The session data could not be serialized.",
|
|
178
|
+
this.#config.driver
|
|
179
|
+
)
|
|
180
|
+
},
|
|
181
|
+
{ cause: err }
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
await storage.setItem(key, serialized);
|
|
185
|
+
this.#dirty = false;
|
|
186
|
+
}
|
|
187
|
+
if (this.#toDestroy.size > 0) {
|
|
188
|
+
const cleanupPromises = [...this.#toDestroy].map(
|
|
189
|
+
(sessionId) => storage.removeItem(sessionId).catch((err) => {
|
|
190
|
+
console.error(`Failed to clean up session ${sessionId}:`, err);
|
|
191
|
+
})
|
|
192
|
+
);
|
|
193
|
+
await Promise.all(cleanupPromises);
|
|
194
|
+
this.#toDestroy.clear();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
get sessionID() {
|
|
198
|
+
return this.#sessionID;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Sets the session cookie.
|
|
202
|
+
*/
|
|
203
|
+
async #setCookie() {
|
|
204
|
+
if (!VALID_COOKIE_REGEX.test(this.#cookieName)) {
|
|
205
|
+
throw new AstroError({
|
|
206
|
+
...SessionStorageSaveError,
|
|
207
|
+
message: "Invalid cookie name. Cookie names can only contain letters, numbers, and dashes."
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const cookieOptions = {
|
|
211
|
+
sameSite: "lax",
|
|
212
|
+
secure: true,
|
|
213
|
+
path: "/",
|
|
214
|
+
...this.#cookieConfig,
|
|
215
|
+
httpOnly: true
|
|
216
|
+
};
|
|
217
|
+
const value = this.#ensureSessionID();
|
|
218
|
+
this.#cookies.set(this.#cookieName, value, cookieOptions);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Attempts to load the session data from storage, or creates a new data object if none exists.
|
|
222
|
+
* If there is existing partial data, it will be merged into the new data object.
|
|
223
|
+
*/
|
|
224
|
+
async #ensureData() {
|
|
225
|
+
const storage = await this.#ensureStorage();
|
|
226
|
+
if (this.#data && !this.#partial) {
|
|
227
|
+
return this.#data;
|
|
228
|
+
}
|
|
229
|
+
this.#data ??= /* @__PURE__ */ new Map();
|
|
230
|
+
const raw = await storage.get(this.#ensureSessionID());
|
|
231
|
+
if (!raw) {
|
|
232
|
+
return this.#data;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const storedMap = unflatten(raw, {
|
|
236
|
+
// Revive URL objects
|
|
237
|
+
URL: (href) => new URL(href)
|
|
238
|
+
});
|
|
239
|
+
if (!(storedMap instanceof Map)) {
|
|
240
|
+
await this.#destroySafe();
|
|
241
|
+
throw new AstroError({
|
|
242
|
+
...SessionStorageInitError,
|
|
243
|
+
message: SessionStorageInitError.message(
|
|
244
|
+
"The session data was an invalid type.",
|
|
245
|
+
this.#config.driver
|
|
246
|
+
)
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const now = Date.now();
|
|
250
|
+
for (const [key, value] of storedMap) {
|
|
251
|
+
const expired = typeof value.expires === "number" && value.expires < now;
|
|
252
|
+
if (!this.#data.has(key) && !this.#toDelete.has(key) && !expired) {
|
|
253
|
+
this.#data.set(key, value);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
this.#partial = false;
|
|
257
|
+
return this.#data;
|
|
258
|
+
} catch (err) {
|
|
259
|
+
await this.#destroySafe();
|
|
260
|
+
if (err instanceof AstroError) {
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
throw new AstroError(
|
|
264
|
+
{
|
|
265
|
+
...SessionStorageInitError,
|
|
266
|
+
message: SessionStorageInitError.message(
|
|
267
|
+
"The session data could not be parsed.",
|
|
268
|
+
this.#config.driver
|
|
269
|
+
)
|
|
270
|
+
},
|
|
271
|
+
{ cause: err }
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Safely destroys the session, clearing the cookie and storage if it exists.
|
|
277
|
+
*/
|
|
278
|
+
#destroySafe() {
|
|
279
|
+
if (this.#sessionID) {
|
|
280
|
+
this.#toDestroy.add(this.#sessionID);
|
|
281
|
+
}
|
|
282
|
+
if (this.#cookieName) {
|
|
283
|
+
this.#cookies.delete(this.#cookieName);
|
|
284
|
+
}
|
|
285
|
+
this.#sessionID = void 0;
|
|
286
|
+
this.#data = void 0;
|
|
287
|
+
this.#dirty = true;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Returns the session ID, generating a new one if it does not exist.
|
|
291
|
+
*/
|
|
292
|
+
#ensureSessionID() {
|
|
293
|
+
this.#sessionID ??= this.#cookies.get(this.#cookieName)?.value ?? crypto.randomUUID();
|
|
294
|
+
return this.#sessionID;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Ensures the storage is initialized.
|
|
298
|
+
* This is called automatically when a storage operation is needed.
|
|
299
|
+
*/
|
|
300
|
+
async #ensureStorage() {
|
|
301
|
+
if (this.#storage) {
|
|
302
|
+
return this.#storage;
|
|
303
|
+
}
|
|
304
|
+
if (this.#config.driver === "test") {
|
|
305
|
+
this.#storage = this.#config.options.mockStorage;
|
|
306
|
+
return this.#storage;
|
|
307
|
+
}
|
|
308
|
+
if (this.#config.driver === "fs" || this.#config.driver === "fsLite" || this.#config.driver === "fs-lite") {
|
|
309
|
+
this.#config.options ??= {};
|
|
310
|
+
this.#config.driver = "fs-lite";
|
|
311
|
+
this.#config.options.base ??= ".astro/session";
|
|
312
|
+
}
|
|
313
|
+
if (!this.#config?.driver) {
|
|
314
|
+
throw new AstroError({
|
|
315
|
+
...SessionStorageInitError,
|
|
316
|
+
message: SessionStorageInitError.message(
|
|
317
|
+
"No driver was defined in the session configuration and the adapter did not provide a default driver."
|
|
318
|
+
)
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
let driver = null;
|
|
322
|
+
const driverPackage = await resolveSessionDriver(this.#config.driver);
|
|
323
|
+
try {
|
|
324
|
+
if (this.#config.driverModule) {
|
|
325
|
+
driver = (await this.#config.driverModule()).default;
|
|
326
|
+
} else if (driverPackage) {
|
|
327
|
+
driver = (await import(driverPackage)).default;
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
if (err.code === "ERR_MODULE_NOT_FOUND") {
|
|
331
|
+
throw new AstroError(
|
|
332
|
+
{
|
|
333
|
+
...SessionStorageInitError,
|
|
334
|
+
message: SessionStorageInitError.message(
|
|
335
|
+
err.message.includes(`Cannot find package '${driverPackage}'`) ? "The driver module could not be found." : err.message,
|
|
336
|
+
this.#config.driver
|
|
337
|
+
)
|
|
338
|
+
},
|
|
339
|
+
{ cause: err }
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
throw err;
|
|
343
|
+
}
|
|
344
|
+
if (!driver) {
|
|
345
|
+
throw new AstroError({
|
|
346
|
+
...SessionStorageInitError,
|
|
347
|
+
message: SessionStorageInitError.message(
|
|
348
|
+
"The module did not export a driver.",
|
|
349
|
+
this.#config.driver
|
|
350
|
+
)
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
this.#storage = createStorage({
|
|
355
|
+
driver: driver(this.#config.options)
|
|
356
|
+
});
|
|
357
|
+
return this.#storage;
|
|
358
|
+
} catch (err) {
|
|
359
|
+
throw new AstroError(
|
|
360
|
+
{
|
|
361
|
+
...SessionStorageInitError,
|
|
362
|
+
message: SessionStorageInitError.message("Unknown error", this.#config.driver)
|
|
363
|
+
},
|
|
364
|
+
{ cause: err }
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function resolveSessionDriver(driver) {
|
|
370
|
+
if (!driver) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
if (driver === "fs") {
|
|
374
|
+
return import.meta.resolve(builtinDrivers.fsLite);
|
|
375
|
+
}
|
|
376
|
+
if (driver in builtinDrivers) {
|
|
377
|
+
return import.meta.resolve(builtinDrivers[driver]);
|
|
378
|
+
}
|
|
379
|
+
return driver;
|
|
380
|
+
}
|
|
381
|
+
export {
|
|
382
|
+
AstroSession,
|
|
383
|
+
PERSIST_SYMBOL,
|
|
384
|
+
resolveSessionDriver
|
|
385
|
+
};
|
package/dist/i18n/middleware.js
CHANGED
|
@@ -20,12 +20,12 @@ function createI18nMiddleware(i18n, base, trailingSlash, format) {
|
|
|
20
20
|
const _noFoundForNonLocaleRoute = notFound(payload);
|
|
21
21
|
const _requestHasLocale = requestHasLocale(payload.locales);
|
|
22
22
|
const _redirectToFallback = redirectToFallback(payload);
|
|
23
|
-
const prefixAlways = (context) => {
|
|
23
|
+
const prefixAlways = (context, response) => {
|
|
24
24
|
const url = context.url;
|
|
25
25
|
if (url.pathname === base + "/" || url.pathname === base) {
|
|
26
26
|
return _redirectToDefaultLocale(context);
|
|
27
27
|
} else if (!_requestHasLocale(context)) {
|
|
28
|
-
return _noFoundForNonLocaleRoute(context);
|
|
28
|
+
return _noFoundForNonLocaleRoute(context, response);
|
|
29
29
|
}
|
|
30
30
|
return void 0;
|
|
31
31
|
};
|
|
@@ -96,7 +96,7 @@ function createI18nMiddleware(i18n, base, trailingSlash, format) {
|
|
|
96
96
|
break;
|
|
97
97
|
}
|
|
98
98
|
case "pathname-prefix-always": {
|
|
99
|
-
const result = prefixAlways(context);
|
|
99
|
+
const result = prefixAlways(context, response);
|
|
100
100
|
if (result) {
|
|
101
101
|
return result;
|
|
102
102
|
}
|
|
@@ -104,7 +104,7 @@ function createI18nMiddleware(i18n, base, trailingSlash, format) {
|
|
|
104
104
|
}
|
|
105
105
|
case "domains-prefix-always": {
|
|
106
106
|
if (localeHasntDomain(i18n, currentLocale)) {
|
|
107
|
-
const result = prefixAlways(context);
|
|
107
|
+
const result = prefixAlways(context, response);
|
|
108
108
|
if (result) {
|
|
109
109
|
return result;
|
|
110
110
|
}
|
|
@@ -67,7 +67,7 @@ export interface Page<T = any> {
|
|
|
67
67
|
next: string | undefined;
|
|
68
68
|
/** url of the first page (if the current page is not the first page) */
|
|
69
69
|
first: string | undefined;
|
|
70
|
-
/** url of the
|
|
70
|
+
/** url of the last page (if the current page is not the last page) */
|
|
71
71
|
last: string | undefined;
|
|
72
72
|
};
|
|
73
73
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OutgoingHttpHeaders } from 'node:http';
|
|
2
2
|
import type { RehypePlugins, RemarkPlugins, RemarkRehype, ShikiConfig } from '@astrojs/markdown-remark';
|
|
3
|
+
import type { BuiltinDriverName, BuiltinDriverOptions, Driver, Storage } from 'unstorage';
|
|
3
4
|
import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite';
|
|
4
5
|
import type { ImageFit, ImageLayout } from '../../assets/types.js';
|
|
5
6
|
import type { RemotePattern } from '../../assets/utils/remotePattern.js';
|
|
@@ -7,6 +8,7 @@ import type { SvgRenderMode } from '../../assets/utils/svg.js';
|
|
|
7
8
|
import type { AssetsPrefix } from '../../core/app/types.js';
|
|
8
9
|
import type { AstroConfigType } from '../../core/config/schema.js';
|
|
9
10
|
import type { REDIRECT_STATUS_CODES } from '../../core/constants.js';
|
|
11
|
+
import type { AstroCookieSetOptions } from '../../core/cookies/cookies.js';
|
|
10
12
|
import type { LoggerLevel } from '../../core/logger/core.js';
|
|
11
13
|
import type { EnvSchema } from '../../env/schema.js';
|
|
12
14
|
import type { AstroIntegration } from './integrations.js';
|
|
@@ -79,6 +81,41 @@ export type ServerConfig = {
|
|
|
79
81
|
*/
|
|
80
82
|
open?: string | boolean;
|
|
81
83
|
};
|
|
84
|
+
export type SessionDriverName = BuiltinDriverName | 'custom' | 'test';
|
|
85
|
+
interface CommonSessionConfig {
|
|
86
|
+
/**
|
|
87
|
+
* Configures the session cookie. If set to a string, it will be used as the cookie name.
|
|
88
|
+
* Alternatively, you can pass an object with additional options.
|
|
89
|
+
*/
|
|
90
|
+
cookie?: string | (Omit<AstroCookieSetOptions, 'httpOnly' | 'expires' | 'encode'> & {
|
|
91
|
+
name?: string;
|
|
92
|
+
});
|
|
93
|
+
/**
|
|
94
|
+
* Default session duration in seconds. If not set, the session will be stored until deleted, or until the cookie expires.
|
|
95
|
+
*/
|
|
96
|
+
ttl?: number;
|
|
97
|
+
}
|
|
98
|
+
interface BuiltinSessionConfig<TDriver extends keyof BuiltinDriverOptions> extends CommonSessionConfig {
|
|
99
|
+
driver: TDriver;
|
|
100
|
+
options?: BuiltinDriverOptions[TDriver];
|
|
101
|
+
}
|
|
102
|
+
interface CustomSessionConfig extends CommonSessionConfig {
|
|
103
|
+
/** Entrypoint for a custom session driver */
|
|
104
|
+
driver: string;
|
|
105
|
+
options?: Record<string, unknown>;
|
|
106
|
+
}
|
|
107
|
+
interface TestSessionConfig extends CommonSessionConfig {
|
|
108
|
+
driver: 'test';
|
|
109
|
+
options: {
|
|
110
|
+
mockStorage: Storage;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export type SessionConfig<TDriver extends SessionDriverName> = TDriver extends keyof BuiltinDriverOptions ? BuiltinSessionConfig<TDriver> : TDriver extends 'test' ? TestSessionConfig : CustomSessionConfig;
|
|
114
|
+
export type ResolvedSessionConfig<TDriver extends SessionDriverName> = SessionConfig<TDriver> & {
|
|
115
|
+
driverModule?: () => Promise<{
|
|
116
|
+
default: () => Driver;
|
|
117
|
+
}>;
|
|
118
|
+
};
|
|
82
119
|
export interface ViteUserConfig extends OriginalViteUserConfig {
|
|
83
120
|
ssr?: ViteSSROptions;
|
|
84
121
|
}
|
|
@@ -87,7 +124,7 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
|
|
|
87
124
|
* Docs: https://docs.astro.build/reference/configuration-reference/
|
|
88
125
|
*
|
|
89
126
|
* Generics do not follow semver and may change at any time.
|
|
90
|
-
*/ export interface AstroUserConfig<TLocales extends Locales = never> {
|
|
127
|
+
*/ export interface AstroUserConfig<TLocales extends Locales = never, TSession extends SessionDriverName = never> {
|
|
91
128
|
/**
|
|
92
129
|
* @docs
|
|
93
130
|
* @kind heading
|
|
@@ -515,8 +552,8 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
|
|
|
515
552
|
*
|
|
516
553
|
* #### Effect on Astro.url
|
|
517
554
|
* Setting `build.format` controls what `Astro.url` is set to during the build. When it is:
|
|
518
|
-
* - `directory` - The `Astro.url.pathname` will include a trailing slash to mimic folder behavior
|
|
519
|
-
* - `file` - The `Astro.url.pathname` will include `.html
|
|
555
|
+
* - `directory` - The `Astro.url.pathname` will include a trailing slash to mimic folder behavior. (e.g. `/foo/`)
|
|
556
|
+
* - `file` - The `Astro.url.pathname` will include `.html`. (e.g. `/foo.html`)
|
|
520
557
|
*
|
|
521
558
|
* This means that when you create relative URLs using `new URL('./relative', Astro.url)`, you will get consistent behavior between dev and build.
|
|
522
559
|
*
|
|
@@ -1818,6 +1855,49 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
|
|
|
1818
1855
|
* The `densities` attribute is not compatible with responsive images and will be ignored if set.
|
|
1819
1856
|
*/
|
|
1820
1857
|
responsiveImages?: boolean;
|
|
1858
|
+
/**
|
|
1859
|
+
*
|
|
1860
|
+
* @name experimental.session
|
|
1861
|
+
* @type {SessionConfig}
|
|
1862
|
+
* @version 5.0.0
|
|
1863
|
+
* @description
|
|
1864
|
+
*
|
|
1865
|
+
* Enables support for sessions in Astro. Sessions are used to store user data across requests, such as user authentication state.
|
|
1866
|
+
*
|
|
1867
|
+
* When enabled you can access the `Astro.session` object to read and write data that persists across requests. You can configure the session driver using the [`session` option](#session), or use the default provided by your adapter.
|
|
1868
|
+
*
|
|
1869
|
+
* ```astro title=src/components/CartButton.astro
|
|
1870
|
+
* ---
|
|
1871
|
+
* export const prerender = false; // Not needed in 'server' mode
|
|
1872
|
+
* const cart = await Astro.session.get('cart');
|
|
1873
|
+
* ---
|
|
1874
|
+
*
|
|
1875
|
+
* <a href="/checkout">🛒 {cart?.length ?? 0} items</a>
|
|
1876
|
+
*
|
|
1877
|
+
* ```
|
|
1878
|
+
* The object configures session management for your Astro site by specifying a `driver` as well as any `options` for your data storage.
|
|
1879
|
+
*
|
|
1880
|
+
* You can specify [any driver from Unstorage](https://unstorage.unjs.io/drivers) or provide a custom config which will override your adapter's default.
|
|
1881
|
+
*
|
|
1882
|
+
* ```js title="astro.config.mjs"
|
|
1883
|
+
* {
|
|
1884
|
+
* experimental: {
|
|
1885
|
+
* session: {
|
|
1886
|
+
* // Required: the name of the Unstorage driver
|
|
1887
|
+
* driver: "redis",
|
|
1888
|
+
* // The required options depend on the driver
|
|
1889
|
+
* options: {
|
|
1890
|
+
* url: process.env.REDIS_URL,
|
|
1891
|
+
* }
|
|
1892
|
+
* }
|
|
1893
|
+
* },
|
|
1894
|
+
* }
|
|
1895
|
+
* ```
|
|
1896
|
+
*
|
|
1897
|
+
* For more details, see [the Sessions RFC](https://github.com/withastro/roadmap/blob/sessions/proposals/0054-sessions.md).
|
|
1898
|
+
*
|
|
1899
|
+
*/
|
|
1900
|
+
session?: SessionConfig<TSession>;
|
|
1821
1901
|
/**
|
|
1822
1902
|
*
|
|
1823
1903
|
* @name experimental.svg
|
|
@@ -2,6 +2,7 @@ import type { z } from 'zod';
|
|
|
2
2
|
import type { ActionAccept, ActionClient, ActionReturnType } from '../../actions/runtime/virtual/server.js';
|
|
3
3
|
import type { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../core/constants.js';
|
|
4
4
|
import type { AstroCookies } from '../../core/cookies/cookies.js';
|
|
5
|
+
import type { AstroSession } from '../../core/session.js';
|
|
5
6
|
import type { AstroComponentFactory } from '../../runtime/server/index.js';
|
|
6
7
|
import type { Params, RewritePayload } from './common.js';
|
|
7
8
|
import type { ValidRedirectStatus } from './config.js';
|
|
@@ -239,6 +240,10 @@ interface AstroSharedContext<Props extends Record<string, any> = Record<string,
|
|
|
239
240
|
* Utility for getting and setting the values of cookies.
|
|
240
241
|
*/
|
|
241
242
|
cookies: AstroCookies;
|
|
243
|
+
/**
|
|
244
|
+
* Utility for handling sessions.
|
|
245
|
+
*/
|
|
246
|
+
session?: AstroSession;
|
|
242
247
|
/**
|
|
243
248
|
* Information about the current request. This is a standard [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object
|
|
244
249
|
*/
|
|
@@ -14,6 +14,7 @@ import { getProps } from "../core/render/index.js";
|
|
|
14
14
|
import { createRequest } from "../core/request.js";
|
|
15
15
|
import { redirectTemplate } from "../core/routing/3xx.js";
|
|
16
16
|
import { matchAllRoutes } from "../core/routing/index.js";
|
|
17
|
+
import { PERSIST_SYMBOL } from "../core/session.js";
|
|
17
18
|
import { getSortedPreloadedMatches } from "../prerender/routing.js";
|
|
18
19
|
import { writeSSRResult, writeWebResponse } from "./response.js";
|
|
19
20
|
function isLoggedRequest(url) {
|
|
@@ -158,6 +159,8 @@ async function handleRoute({
|
|
|
158
159
|
renderContext.props.error = err;
|
|
159
160
|
response = await renderContext.render(preloaded500Component);
|
|
160
161
|
statusCode = 500;
|
|
162
|
+
} finally {
|
|
163
|
+
renderContext.session?.[PERSIST_SYMBOL]();
|
|
161
164
|
}
|
|
162
165
|
if (isLoggedRequest(pathname)) {
|
|
163
166
|
const timeEnd = performance.now();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astro",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.1",
|
|
4
4
|
"description": "Astro is a modern site builder with web best practices, performance, and DX front-of-mind.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "withastro",
|
|
@@ -148,8 +148,9 @@
|
|
|
148
148
|
"tsconfck": "^3.1.4",
|
|
149
149
|
"ultrahtml": "^1.5.3",
|
|
150
150
|
"unist-util-visit": "^5.0.0",
|
|
151
|
+
"unstorage": "^1.14.0",
|
|
151
152
|
"vfile": "^6.0.3",
|
|
152
|
-
"vite": "^6.0.
|
|
153
|
+
"vite": "^6.0.5",
|
|
153
154
|
"vitefu": "^1.0.4",
|
|
154
155
|
"which-pm": "^3.0.0",
|
|
155
156
|
"xxhash-wasm": "^1.1.0",
|
|
@@ -159,8 +160,8 @@
|
|
|
159
160
|
"zod-to-json-schema": "^3.23.5",
|
|
160
161
|
"zod-to-ts": "^1.2.0",
|
|
161
162
|
"@astrojs/internal-helpers": "0.4.2",
|
|
162
|
-
"@astrojs/
|
|
163
|
-
"@astrojs/
|
|
163
|
+
"@astrojs/markdown-remark": "6.0.1",
|
|
164
|
+
"@astrojs/telemetry": "3.2.0"
|
|
164
165
|
},
|
|
165
166
|
"optionalDependencies": {
|
|
166
167
|
"sharp": "^0.33.3"
|
package/templates/actions.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ACTION_QUERY_PARAMS,
|
|
2
3
|
ActionError,
|
|
3
4
|
appendForwardSlash,
|
|
4
5
|
deserializeActionResult,
|
|
@@ -52,6 +53,17 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '') {
|
|
|
52
53
|
});
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
const SHOULD_APPEND_TRAILING_SLASH = '/** @TRAILING_SLASH@ **/';
|
|
57
|
+
|
|
58
|
+
/** @param {import('astro:actions').ActionClient<any, any, any>} */
|
|
59
|
+
export function getActionPath(action) {
|
|
60
|
+
let path = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/_actions/${new URLSearchParams(action.toString()).get(ACTION_QUERY_PARAMS.actionName)}`;
|
|
61
|
+
if (SHOULD_APPEND_TRAILING_SLASH) {
|
|
62
|
+
path = appendForwardSlash(path);
|
|
63
|
+
}
|
|
64
|
+
return path;
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
/**
|
|
56
68
|
* @param {*} param argument passed to the action when called server or client-side.
|
|
57
69
|
* @param {string} path Built path to call action by path name.
|
|
@@ -88,19 +100,19 @@ async function handleAction(param, path, context) {
|
|
|
88
100
|
headers.set('Content-Length', '0');
|
|
89
101
|
}
|
|
90
102
|
}
|
|
103
|
+
const rawResult = await fetch(
|
|
104
|
+
getActionPath({
|
|
105
|
+
toString() {
|
|
106
|
+
return getActionQueryString(path);
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
{
|
|
110
|
+
method: 'POST',
|
|
111
|
+
body,
|
|
112
|
+
headers,
|
|
113
|
+
},
|
|
114
|
+
);
|
|
91
115
|
|
|
92
|
-
const shouldAppendTrailingSlash = '/** @TRAILING_SLASH@ **/';
|
|
93
|
-
let actionPath = import.meta.env.BASE_URL.replace(/\/$/, '') + '/_actions/' + path;
|
|
94
|
-
|
|
95
|
-
if (shouldAppendTrailingSlash) {
|
|
96
|
-
actionPath = appendForwardSlash(actionPath);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const rawResult = await fetch(actionPath, {
|
|
100
|
-
method: 'POST',
|
|
101
|
-
body,
|
|
102
|
-
headers,
|
|
103
|
-
});
|
|
104
116
|
if (rawResult.status === 204) {
|
|
105
117
|
return deserializeActionResult({ type: 'empty', status: 204 });
|
|
106
118
|
}
|