@php-wasm/universal 0.1.40 → 0.1.41

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.
@@ -1,137 +0,0 @@
1
- import type { PHPRequestHandler } from './php-request-handler';
2
- import type { PHPResponse } from './php-response';
3
- import { PHPRequest, RequestHandler } from './universal-php';
4
-
5
- export interface PHPBrowserConfiguration {
6
- /**
7
- * Should handle redirects internally?
8
- */
9
- handleRedirects?: boolean;
10
- /**
11
- * The maximum number of redirects to follow internally. Once
12
- * exceeded, request() will return the redirecting response.
13
- */
14
- maxRedirects?: number;
15
- }
16
-
17
- /**
18
- * A fake web browser that handles PHPRequestHandler's cookies and redirects
19
- * internally without exposing them to the consumer.
20
- *
21
- * @public
22
- */
23
- export class PHPBrowser implements RequestHandler {
24
- #cookies: Record<string, string>;
25
- #config;
26
-
27
- requestHandler: PHPRequestHandler;
28
-
29
- /**
30
- * @param server - The PHP server to browse.
31
- * @param config - The browser configuration.
32
- */
33
- constructor(
34
- requestHandler: PHPRequestHandler,
35
- config: PHPBrowserConfiguration = {}
36
- ) {
37
- this.requestHandler = requestHandler;
38
- this.#cookies = {};
39
- this.#config = {
40
- handleRedirects: false,
41
- maxRedirects: 4,
42
- ...config,
43
- };
44
- }
45
-
46
- /**
47
- * Sends the request to the server.
48
- *
49
- * When cookies are present in the response, this method stores
50
- * them and sends them with any subsequent requests.
51
- *
52
- * When a redirection is present in the response, this method
53
- * follows it by discarding a response and sending a subsequent
54
- * request.
55
- *
56
- * @param request - The request.
57
- * @param redirects - Internal. The number of redirects handled so far.
58
- * @returns PHPRequestHandler response.
59
- */
60
- async request(request: PHPRequest, redirects = 0): Promise<PHPResponse> {
61
- const response = await this.requestHandler.request({
62
- ...request,
63
- headers: {
64
- ...request.headers,
65
- cookie: this.#serializeCookies(),
66
- },
67
- });
68
-
69
- if (response.headers['set-cookie']) {
70
- this.#setCookies(response.headers['set-cookie']);
71
- }
72
-
73
- if (
74
- this.#config.handleRedirects &&
75
- response.headers['location'] &&
76
- redirects < this.#config.maxRedirects
77
- ) {
78
- const redirectUrl = new URL(
79
- response.headers['location'][0],
80
- this.requestHandler.absoluteUrl
81
- );
82
- return this.request(
83
- {
84
- url: redirectUrl.toString(),
85
- method: 'GET',
86
- headers: {},
87
- },
88
- redirects + 1
89
- );
90
- }
91
-
92
- return response;
93
- }
94
-
95
- /** @inheritDoc */
96
- pathToInternalUrl(path: string) {
97
- return this.requestHandler.pathToInternalUrl(path);
98
- }
99
-
100
- /** @inheritDoc */
101
- internalUrlToPath(internalUrl: string) {
102
- return this.requestHandler.internalUrlToPath(internalUrl);
103
- }
104
-
105
- /** @inheritDoc */
106
- get absoluteUrl() {
107
- return this.requestHandler.absoluteUrl;
108
- }
109
-
110
- /** @inheritDoc */
111
- get documentRoot() {
112
- return this.requestHandler.documentRoot;
113
- }
114
- #setCookies(cookies: string[]) {
115
- for (const cookie of cookies) {
116
- try {
117
- if (!cookie.includes('=')) {
118
- continue;
119
- }
120
- const equalsIndex = cookie.indexOf('=');
121
- const name = cookie.substring(0, equalsIndex);
122
- const value = cookie.substring(equalsIndex + 1).split(';')[0];
123
- this.#cookies[name] = value;
124
- } catch (e) {
125
- console.error(e);
126
- }
127
- }
128
- }
129
-
130
- #serializeCookies() {
131
- const cookiesArray: string[] = [];
132
- for (const name in this.#cookies) {
133
- cookiesArray.push(`${name}=${this.#cookies[name]}`);
134
- }
135
- return cookiesArray.join('; ');
136
- }
137
- }
@@ -1,381 +0,0 @@
1
- import { Semaphore } from '@php-wasm/util';
2
- import {
3
- ensurePathPrefix,
4
- toRelativeUrl,
5
- removePathPrefix,
6
- DEFAULT_BASE_URL,
7
- } from './urls';
8
- import { BasePHP, normalizeHeaders } from './base-php';
9
- import { PHPResponse } from './php-response';
10
- import {
11
- FileInfo,
12
- PHPRequest,
13
- PHPRunOptions,
14
- RequestHandler,
15
- } from './universal-php';
16
-
17
- export interface PHPRequestHandlerConfiguration {
18
- /**
19
- * The directory in the PHP filesystem where the server will look
20
- * for the files to serve. Default: `/var/www`.
21
- */
22
- documentRoot?: string;
23
- /**
24
- * Request Handler URL. Used to populate $_SERVER details like HTTP_HOST.
25
- */
26
- absoluteUrl?: string;
27
- /**
28
- * Callback used by the PHPRequestHandler to decide whether
29
- * the requested path refers to a PHP file or a static file.
30
- */
31
- isStaticFilePath?: (path: string) => boolean;
32
- }
33
-
34
- /** @inheritDoc */
35
- export class PHPRequestHandler implements RequestHandler {
36
- #DOCROOT: string;
37
- #PROTOCOL: string;
38
- #HOSTNAME: string;
39
- #PORT: number;
40
- #HOST: string;
41
- #PATHNAME: string;
42
- #ABSOLUTE_URL: string;
43
- #semaphore: Semaphore;
44
-
45
- /**
46
- * The PHP instance
47
- */
48
- php: BasePHP;
49
- #isStaticFilePath: (path: string) => boolean;
50
-
51
- /**
52
- * @param php - The PHP instance.
53
- * @param config - Request Handler configuration.
54
- */
55
- constructor(php: BasePHP, config: PHPRequestHandlerConfiguration = {}) {
56
- this.#semaphore = new Semaphore({ concurrency: 1 });
57
- const {
58
- documentRoot = '/www/',
59
- absoluteUrl = typeof location === 'object' ? location?.href : '',
60
- isStaticFilePath = () => false,
61
- } = config;
62
- this.php = php;
63
- this.#DOCROOT = documentRoot;
64
- this.#isStaticFilePath = isStaticFilePath;
65
-
66
- const url = new URL(absoluteUrl);
67
- this.#HOSTNAME = url.hostname;
68
- this.#PORT = url.port
69
- ? Number(url.port)
70
- : url.protocol === 'https:'
71
- ? 443
72
- : 80;
73
- this.#PROTOCOL = (url.protocol || '').replace(':', '');
74
- const isNonStandardPort = this.#PORT !== 443 && this.#PORT !== 80;
75
- this.#HOST = [
76
- this.#HOSTNAME,
77
- isNonStandardPort ? `:${this.#PORT}` : '',
78
- ].join('');
79
- this.#PATHNAME = url.pathname.replace(/\/+$/, '');
80
- this.#ABSOLUTE_URL = [
81
- `${this.#PROTOCOL}://`,
82
- this.#HOST,
83
- this.#PATHNAME,
84
- ].join('');
85
- }
86
-
87
- /** @inheritDoc */
88
- pathToInternalUrl(path: string): string {
89
- return `${this.absoluteUrl}${path}`;
90
- }
91
-
92
- /** @inheritDoc */
93
- internalUrlToPath(internalUrl: string): string {
94
- const url = new URL(internalUrl);
95
- if (url.pathname.startsWith(this.#PATHNAME)) {
96
- url.pathname = url.pathname.slice(this.#PATHNAME.length);
97
- }
98
- return toRelativeUrl(url);
99
- }
100
-
101
- get isRequestRunning() {
102
- return this.#semaphore.running > 0;
103
- }
104
-
105
- /** @inheritDoc */
106
- get absoluteUrl() {
107
- return this.#ABSOLUTE_URL;
108
- }
109
-
110
- /** @inheritDoc */
111
- get documentRoot() {
112
- return this.#DOCROOT;
113
- }
114
-
115
- /** @inheritDoc */
116
- async request(request: PHPRequest): Promise<PHPResponse> {
117
- const isAbsolute =
118
- request.url.startsWith('http://') ||
119
- request.url.startsWith('https://');
120
- const requestedUrl = new URL(
121
- request.url,
122
- isAbsolute ? undefined : DEFAULT_BASE_URL
123
- );
124
-
125
- const normalizedRelativeUrl = removePathPrefix(
126
- requestedUrl.pathname,
127
- this.#PATHNAME
128
- );
129
- if (this.#isStaticFilePath(normalizedRelativeUrl)) {
130
- return this.#serveStaticFile(normalizedRelativeUrl);
131
- }
132
- return await this.#dispatchToPHP(request, requestedUrl);
133
- }
134
-
135
- /**
136
- * Serves a static file from the PHP filesystem.
137
- *
138
- * @param path - The requested static file path.
139
- * @returns The response.
140
- */
141
- #serveStaticFile(path: string): PHPResponse {
142
- const fsPath = `${this.#DOCROOT}${path}`;
143
-
144
- if (!this.php.fileExists(fsPath)) {
145
- return new PHPResponse(
146
- 404,
147
- {},
148
- new TextEncoder().encode('404 File not found')
149
- );
150
- }
151
- const arrayBuffer = this.php.readFileAsBuffer(fsPath);
152
- return new PHPResponse(
153
- 200,
154
- {
155
- 'content-length': [`${arrayBuffer.byteLength}`],
156
- // @TODO: Infer the content-type from the arrayBuffer instead of the file path.
157
- // The code below won't return the correct mime-type if the extension
158
- // was tampered with.
159
- 'content-type': [inferMimeType(fsPath)],
160
- 'accept-ranges': ['bytes'],
161
- 'cache-control': ['public, max-age=0'],
162
- },
163
- arrayBuffer
164
- );
165
- }
166
-
167
- /**
168
- * Runs the requested PHP file with all the request and $_SERVER
169
- * superglobals populated.
170
- *
171
- * @param request - The request.
172
- * @returns The response.
173
- */
174
- async #dispatchToPHP(
175
- request: PHPRequest,
176
- requestedUrl: URL
177
- ): Promise<PHPResponse> {
178
- /*
179
- * Prevent multiple requests from running at the same time.
180
- * For example, if a request is made to a PHP file that
181
- * requests another PHP file, the second request may
182
- * be dispatched before the first one is finished.
183
- */
184
- const release = await this.#semaphore.acquire();
185
- try {
186
- this.php.addServerGlobalEntry('DOCUMENT_ROOT', this.#DOCROOT);
187
- this.php.addServerGlobalEntry(
188
- 'HTTPS',
189
- this.#ABSOLUTE_URL.startsWith('https://') ? 'on' : ''
190
- );
191
-
192
- let preferredMethod: PHPRunOptions['method'] = 'GET';
193
-
194
- const headers: Record<string, string> = {
195
- host: this.#HOST,
196
- ...normalizeHeaders(request.headers || {}),
197
- };
198
- const fileInfos: FileInfo[] = [];
199
- if (request.files && Object.keys(request.files).length) {
200
- preferredMethod = 'POST';
201
- for (const key in request.files) {
202
- const file: File = request.files[key];
203
- fileInfos.push({
204
- key,
205
- name: file.name,
206
- type: file.type,
207
- data: new Uint8Array(await file.arrayBuffer()),
208
- });
209
- }
210
-
211
- /**
212
- * When the files are present, we can't use the multipart/form-data
213
- * Content-type header. Instead, we rewrite the request body
214
- * to application/x-www-form-urlencoded.
215
- * See the phpwasm_init_uploaded_files_hash() docstring for more details.
216
- */
217
- if (
218
- headers['content-type']?.startsWith('multipart/form-data')
219
- ) {
220
- request.formData = parseMultipartFormDataString(
221
- request.body || ''
222
- );
223
- headers['content-type'] =
224
- 'application/x-www-form-urlencoded';
225
- delete request.body;
226
- }
227
- }
228
-
229
- let body;
230
- if (request.formData !== undefined) {
231
- preferredMethod = 'POST';
232
- headers['content-type'] =
233
- headers['content-type'] ||
234
- 'application/x-www-form-urlencoded';
235
- body = new URLSearchParams(
236
- request.formData as Record<string, string>
237
- ).toString();
238
- } else {
239
- body = request.body;
240
- }
241
-
242
- return await this.php.run({
243
- relativeUri: ensurePathPrefix(
244
- toRelativeUrl(requestedUrl),
245
- this.#PATHNAME
246
- ),
247
- protocol: this.#PROTOCOL,
248
- method: request.method || preferredMethod,
249
- body,
250
- fileInfos,
251
- scriptPath: this.#resolvePHPFilePath(requestedUrl.pathname),
252
- headers,
253
- });
254
- } finally {
255
- release();
256
- }
257
- }
258
-
259
- /**
260
- * Resolve the requested path to the filesystem path of the requested PHP file.
261
- *
262
- * Fall back to index.php as if there was a url rewriting rule in place.
263
- *
264
- * @param requestedPath - The requested pathname.
265
- * @returns The resolved filesystem path.
266
- */
267
- #resolvePHPFilePath(requestedPath: string): string {
268
- let filePath = removePathPrefix(requestedPath, this.#PATHNAME);
269
-
270
- // If the path mentions a .php extension, that's our file's path.
271
- if (filePath.includes('.php')) {
272
- filePath = filePath.split('.php')[0] + '.php';
273
- } else {
274
- // Otherwise, let's assume the file is $request_path/index.php
275
- if (!filePath.endsWith('/')) {
276
- filePath += '/';
277
- }
278
- if (!filePath.endsWith('index.php')) {
279
- filePath += 'index.php';
280
- }
281
- }
282
-
283
- const resolvedFsPath = `${this.#DOCROOT}${filePath}`;
284
- if (this.php.fileExists(resolvedFsPath)) {
285
- return resolvedFsPath;
286
- }
287
- return `${this.#DOCROOT}/index.php`;
288
- }
289
- }
290
-
291
- /**
292
- * Parses a multipart/form-data string into a key-value object.
293
- *
294
- * @param multipartString
295
- * @returns
296
- */
297
- function parseMultipartFormDataString(multipartString: string) {
298
- const parsedData: Record<string, string> = {};
299
-
300
- // Extract the boundary from the string
301
- const boundaryMatch = multipartString.match(/--(.*)\r\n/);
302
- if (!boundaryMatch) {
303
- return parsedData;
304
- }
305
-
306
- const boundary = boundaryMatch[1];
307
-
308
- // Split the string into parts
309
- const parts = multipartString.split(`--${boundary}`);
310
-
311
- // Remove the first and the last part, which are just boundary markers
312
- parts.shift();
313
- parts.pop();
314
-
315
- // Process each part
316
- parts.forEach((part: string) => {
317
- const headerBodySplit = part.indexOf('\r\n\r\n');
318
- const headers = part.substring(0, headerBodySplit).trim();
319
- const body = part.substring(headerBodySplit + 4).trim();
320
-
321
- const nameMatch = headers.match(/name="([^"]+)"/);
322
- if (nameMatch) {
323
- const name = nameMatch[1];
324
- parsedData[name] = body;
325
- }
326
- });
327
-
328
- return parsedData;
329
- }
330
-
331
- /**
332
- * Naively infer a file mime type from its path.
333
- *
334
- * @todo Infer the mime type based on the file contents.
335
- * A naive function like this one can be inaccurate
336
- * and potentially have negative security consequences.
337
- *
338
- * @param path - The file path
339
- * @returns The inferred mime type.
340
- */
341
- function inferMimeType(path: string): string {
342
- const extension = path.split('.').pop();
343
- switch (extension) {
344
- case 'css':
345
- return 'text/css';
346
- case 'js':
347
- return 'application/javascript';
348
- case 'png':
349
- return 'image/png';
350
- case 'jpg':
351
- case 'jpeg':
352
- return 'image/jpeg';
353
- case 'gif':
354
- return 'image/gif';
355
- case 'svg':
356
- return 'image/svg+xml';
357
- case 'woff':
358
- return 'font/woff';
359
- case 'woff2':
360
- return 'font/woff2';
361
- case 'ttf':
362
- return 'font/ttf';
363
- case 'otf':
364
- return 'font/otf';
365
- case 'eot':
366
- return 'font/eot';
367
- case 'ico':
368
- return 'image/x-icon';
369
- case 'html':
370
- return 'text/html';
371
- case 'json':
372
- return 'application/json';
373
- case 'xml':
374
- return 'application/xml';
375
- case 'txt':
376
- case 'md':
377
- return 'text/plain';
378
- default:
379
- return 'application-octet-stream';
380
- }
381
- }
@@ -1,104 +0,0 @@
1
- /*
2
- * This type is used in Comlink.transferHandlers.set('PHPResponse', { ... })
3
- * so be sure to update that if you change this type.
4
- */
5
- export interface PHPResponseData {
6
- /**
7
- * Response headers.
8
- */
9
- readonly headers: Record<string, string[]>;
10
-
11
- /**
12
- * Response body. Contains the output from `echo`,
13
- * `print`, inline HTML etc.
14
- */
15
- readonly bytes: ArrayBuffer;
16
-
17
- /**
18
- * Stderr contents, if any.
19
- */
20
- readonly errors: string;
21
-
22
- /**
23
- * The exit code of the script. `0` is a success, while
24
- * `1` and `2` indicate an error.
25
- */
26
- readonly exitCode: number;
27
-
28
- /**
29
- * Response HTTP status code, e.g. 200.
30
- */
31
- readonly httpStatusCode: number;
32
- }
33
-
34
- /**
35
- * PHP response. Body is an `ArrayBuffer` because it can
36
- * contain binary data.
37
- *
38
- * This type is used in Comlink.transferHandlers.set('PHPResponse', \{ ... \})
39
- * so be sure to update that if you change this type.
40
- */
41
- export class PHPResponse implements PHPResponseData {
42
- /** @inheritDoc */
43
- readonly headers: Record<string, string[]>;
44
-
45
- /** @inheritDoc */
46
- readonly bytes: ArrayBuffer;
47
-
48
- /** @inheritDoc */
49
- readonly errors: string;
50
-
51
- /** @inheritDoc */
52
- readonly exitCode: number;
53
-
54
- /** @inheritDoc */
55
- readonly httpStatusCode: number;
56
-
57
- constructor(
58
- httpStatusCode: number,
59
- headers: Record<string, string[]>,
60
- body: ArrayBuffer,
61
- errors = '',
62
- exitCode = 0
63
- ) {
64
- this.httpStatusCode = httpStatusCode;
65
- this.headers = headers;
66
- this.bytes = body;
67
- this.exitCode = exitCode;
68
- this.errors = errors;
69
- }
70
-
71
- static fromRawData(data: PHPResponseData): PHPResponse {
72
- return new PHPResponse(
73
- data.httpStatusCode,
74
- data.headers,
75
- data.bytes,
76
- data.errors,
77
- data.exitCode
78
- );
79
- }
80
-
81
- toRawData(): PHPResponseData {
82
- return {
83
- headers: this.headers,
84
- bytes: this.bytes,
85
- errors: this.errors,
86
- exitCode: this.exitCode,
87
- httpStatusCode: this.httpStatusCode,
88
- };
89
- }
90
-
91
- /**
92
- * Response body as JSON.
93
- */
94
- get json() {
95
- return JSON.parse(this.text);
96
- }
97
-
98
- /**
99
- * Response body as text.
100
- */
101
- get text() {
102
- return new TextDecoder().decode(this.bytes);
103
- }
104
- }