Skip to content

Commit 172d32e

Browse files
authored
Store WordPress site cookies in the browser instead of a custom Cookie Store (#20)
## Motivation for the change, related issues A cookie store is needed on the web where custom Response objects may not be assigned `Set-Cookie` headers. Otherwise, `Set-Cookie` headers received in PHP responses are effectively discarded. But a cookie store isn't needed for Playground CLI, and applying the same cookies to all requests to a CLI-based server interferes with clients that want to use other WP auth schemes. This PR gives the browser-based PHP worker the responsibility for maintaining a cookie store per Playground scope. This feature was previously implemented by @brandonpayton in [Automattic/wordpress-playground/pull/8](https://github.a8c.com/Automattic/wordpress-playground/pull/8). We also explored using [document.cookie instead of the cookie store](Automattic/wordpress-playground-private#21 (comment)), but we couldn't come up with a clean implementation due to browser restrictions. ## Implementation details This PR makes the cookie store a browser-based PHP worker concern so it can manage which cookie store to apply to which requests. Originally, I wanted to make the Service Worker responsible for the synthetic cookie store because it is responsible for all request routing and generally sits between the browser client and the PHP "server", but it turned out to be quite awkward because: - the Service Worker doesn't currently know when Playground site scopes come and go, making cleanup difficult - Service Workers can be terminated and restarted while client tabs are still open. This meant that we'd have to persist cookies in something like IndexedDB, and both service worker lifetime uncertainty and ignorance about the lifetime of site scopes made it difficult to know when old cookie stores could be cleared. If the cookie store is kept with the PHP worker, then the cookies will naturally come and go as Playground sites are loaded and unloaded. ## Testing Instructions (or ideally a Blueprint) - CI
1 parent 0140ef3 commit 172d32e

File tree

7 files changed

+157
-11
lines changed

7 files changed

+157
-11
lines changed

packages/php-wasm/node/src/test/php-request-handler.spec.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { RecommendedPHPVersion } from '@wp-playground/common';
33
import { getFileNotFoundActionForWordPress } from '@wp-playground/wordpress';
44
import { loadNodeRuntime } from '..';
55
import {
6+
CookieStore,
67
FileNotFoundGetActionCallback,
8+
HttpCookieStore,
79
PHP,
810
PHPRequestHandler,
911
PHPResponse,
@@ -765,3 +767,98 @@ describe('PHPRequestHandler – Loopback call', () => {
765767
expect(response.text).toEqual('Starting: Ran second.php! Done');
766768
});
767769
});
770+
771+
describe('PHPRequestHandler – Cookie store', () => {
772+
const prepareHandler = async (cookieStore?: CookieStore | false) => {
773+
const handler = new PHPRequestHandler({
774+
documentRoot: '/',
775+
phpFactory: async () =>
776+
new PHP(await loadNodeRuntime(RecommendedPHPVersion)),
777+
maxPhpInstances: 1,
778+
cookieStore,
779+
});
780+
const php = await handler.getPrimaryPhp();
781+
php.writeFile(
782+
'/set-cookie.php',
783+
`<?php setcookie("my-cookie", "where-is-my-cookie", time() + 3600, "/");`
784+
);
785+
php.writeFile('/get-cookie.php', `<?php echo json_encode($_COOKIE);`);
786+
return handler;
787+
};
788+
it('should persist cookies internally when not defining a strategy', async () => {
789+
const handler = await prepareHandler();
790+
791+
// Cookies return in the response
792+
let response = await handler.request({
793+
url: '/set-cookie.php',
794+
});
795+
const cookies = response.headers['set-cookie'];
796+
expect(cookies).toHaveLength(1);
797+
expect(cookies[0]).toMatch(
798+
/my-cookie=where-is-my-cookie; expires=.*; Max-Age=3600; path=\//
799+
);
800+
801+
// Cookies are persisted internally in the request handler.
802+
// Note that we are not passing cookies in the header of the response.
803+
response = await handler.request({
804+
url: '/get-cookie.php',
805+
});
806+
expect(response.text).toEqual(
807+
JSON.stringify({ 'my-cookie': 'where-is-my-cookie' })
808+
);
809+
});
810+
811+
it('should persist cookies internally with the HttpCookieStore', async () => {
812+
const handler = await prepareHandler(new HttpCookieStore());
813+
814+
// Cookies return in the response
815+
let response = await handler.request({
816+
url: '/set-cookie.php',
817+
});
818+
const cookies = response.headers['set-cookie'];
819+
expect(cookies).toHaveLength(1);
820+
expect(cookies[0]).toMatch(
821+
/my-cookie=where-is-my-cookie; expires=.*; Max-Age=3600; path=\//
822+
);
823+
824+
// Cookies are persisted internally in the request handler.
825+
// Note that we are not passing cookies in the header of the response.
826+
response = await handler.request({
827+
url: '/get-cookie.php',
828+
});
829+
expect(response.text).toEqual(
830+
JSON.stringify({ 'my-cookie': 'where-is-my-cookie' })
831+
);
832+
});
833+
834+
it('should not persist cookies internally when the cookie store is false', async () => {
835+
const handler = await prepareHandler(false);
836+
837+
// Cookies return in the response
838+
let response = await handler.request({
839+
url: '/set-cookie.php',
840+
});
841+
const cookies = response.headers['set-cookie'];
842+
expect(cookies).toHaveLength(1);
843+
expect(cookies[0]).toMatch(
844+
/my-cookie=where-is-my-cookie; expires=.*; Max-Age=3600; path=\//
845+
);
846+
847+
// No cookies are persisted internally.
848+
// Note that we are not passing cookies in the header of the response.
849+
response = await handler.request({
850+
url: '/get-cookie.php',
851+
});
852+
expect(response.text).toEqual(JSON.stringify([]));
853+
854+
// Cookies are available in the PHP environment when passed in the
855+
// request.
856+
response = await handler.request({
857+
url: '/get-cookie.php',
858+
headers: { Cookie: 'my-cookie=where-is-my-cookie' },
859+
});
860+
expect(response.text).toEqual(
861+
JSON.stringify({ 'my-cookie': 'where-is-my-cookie' })
862+
);
863+
});
864+
});

packages/php-wasm/universal/src/lib/http-cookie-store.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { logger } from '@php-wasm/logger';
2-
2+
import { CookieStore } from './php-request-handler';
33
/**
44
* @public
55
*/
6-
export class HttpCookieStore {
6+
export class HttpCookieStore implements CookieStore {
77
cookies: Record<string, string> = {};
88

99
rememberCookiesFromResponseHeaders(headers: Record<string, string[]>) {

packages/php-wasm/universal/src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export type {
6161
FileNotFoundToInternalRedirect,
6262
FileNotFoundToResponse,
6363
FileNotFoundAction,
64+
CookieStore,
6465
} from './php-request-handler';
6566
export { rotatePHPRuntime } from './rotate-php-runtime';
6667
export { writeFiles } from './write-files';

packages/php-wasm/universal/src/lib/php-request-handler.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,24 @@ export type FileNotFoundGetActionCallback = (
4242
relativePath: string
4343
) => FileNotFoundAction;
4444

45+
/**
46+
* Interface for cookie storage implementations.
47+
* This allows different cookie handling strategies to be used with the PHP request handler.
48+
*/
49+
export interface CookieStore {
50+
/**
51+
* Processes and stores cookies from response headers
52+
* @param headers Response headers containing Set-Cookie directives
53+
*/
54+
rememberCookiesFromResponseHeaders(headers: Record<string, string[]>): void;
55+
56+
/**
57+
* Gets the cookie header string for the next request
58+
* @returns Formatted cookie header string
59+
*/
60+
getCookieRequestHeader(): string;
61+
}
62+
4563
interface BaseConfiguration {
4664
/**
4765
* The directory in the PHP filesystem where the server will look
@@ -95,7 +113,9 @@ export type PHPRequestHandlerConfiguration = BaseConfiguration &
95113
*/
96114
maxPhpInstances?: number;
97115
}
98-
);
116+
) & {
117+
cookieStore?: CookieStore | false;
118+
};
99119

100120
/**
101121
* Handles HTTP requests using PHP runtime as a backend.
@@ -159,7 +179,7 @@ export class PHPRequestHandler {
159179
#HOST: string;
160180
#PATHNAME: string;
161181
#ABSOLUTE_URL: string;
162-
#cookieStore: HttpCookieStore;
182+
#cookieStore: CookieStore | false;
163183
rewriteRules: RewriteRule[];
164184
processManager: PHPProcessManager;
165185
getFileNotFoundAction: FileNotFoundGetActionCallback;
@@ -198,7 +218,11 @@ export class PHPRequestHandler {
198218
maxPhpInstances: config.maxPhpInstances,
199219
});
200220
}
201-
this.#cookieStore = new HttpCookieStore();
221+
222+
this.#cookieStore =
223+
config.cookieStore === undefined
224+
? new HttpCookieStore()
225+
: config.cookieStore;
202226
this.#DOCROOT = documentRoot;
203227

204228
const url = new URL(absoluteUrl);
@@ -490,8 +514,10 @@ export class PHPRequestHandler {
490514
const headers: Record<string, string> = {
491515
host: this.#HOST,
492516
...normalizeHeaders(request.headers || {}),
493-
cookie: this.#cookieStore.getCookieRequestHeader(),
494517
};
518+
if (this.#cookieStore) {
519+
headers['cookie'] = this.#cookieStore.getCookieRequestHeader();
520+
}
495521

496522
let body = request.body;
497523
if (typeof body === 'object' && !(body instanceof Uint8Array)) {
@@ -520,9 +546,11 @@ export class PHPRequestHandler {
520546
scriptPath,
521547
headers,
522548
});
523-
this.#cookieStore.rememberCookiesFromResponseHeaders(
524-
response.headers
525-
);
549+
if (this.#cookieStore) {
550+
this.#cookieStore.rememberCookiesFromResponseHeaders(
551+
response.headers
552+
);
553+
}
526554
return response;
527555
} catch (error) {
528556
const executionError = error as PHPExecutionFailureError;

packages/playground/cli/src/run-cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
281281
}
282282
},
283283
},
284+
cookieStore: false,
284285
});
285286
logger.log(`Booted!`);
286287

packages/playground/website/src/lib/state/redux/slice-sites.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,10 +316,11 @@ export const selectSitesLoaded = createSelector(
316316
state.sites.opfsSitesLoadingState,
317317
(state: { sites: ReturnType<typeof sitesSlice.reducer> }) =>
318318
state.sites.firstTemporarySiteCreated,
319+
(state) => selectActiveSite(state),
319320
],
320-
(opfsSitesLoadingState, firstTemporarySiteCreated) =>
321+
(opfsSitesLoadingState, firstTemporarySiteCreated, activeSite) =>
321322
['loaded', 'error'].includes(opfsSitesLoadingState) &&
322-
firstTemporarySiteCreated
323+
(activeSite?.metadata.storage !== 'none' || firstTemporarySiteCreated)
323324
);
324325

325326
export default sitesSlice.reducer;

packages/playground/wordpress/src/boot.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
CookieStore,
23
FileNotFoundAction,
34
FileNotFoundGetActionCallback,
45
FileTree,
@@ -92,6 +93,22 @@ export interface BootOptions {
9293
* given request URI.
9394
*/
9495
getFileNotFoundAction?: FileNotFoundGetActionCallback;
96+
97+
/**
98+
* The CookieStore instance to use.
99+
*
100+
* If not provided, Playground will use the HttpCookieStore by default.
101+
* The HttpCookieStore persists cookies in an internal store and includes
102+
* them in following requests.
103+
*
104+
* If you don't want Playground to handle cookies, set the cookie store
105+
* to `false`. This is useful for the Node version of Playground, where
106+
* cookies can be handled by the browser.
107+
*
108+
* You can also provide a custom CookieStore implementation by implementing
109+
* the CookieStore interface.
110+
*/
111+
cookieStore?: CookieStore | false;
95112
}
96113

97114
/**
@@ -180,6 +197,7 @@ export async function bootWordPress(options: BootOptions) {
180197
rewriteRules: wordPressRewriteRules,
181198
getFileNotFoundAction:
182199
options.getFileNotFoundAction ?? getFileNotFoundActionForWordPress,
200+
cookieStore: options.cookieStore,
183201
});
184202

185203
const php = await requestHandler.getPrimaryPhp();

0 commit comments

Comments
 (0)