| 1 |
<?php |
| 2 |
/** |
| 3 |
* Classify the current request into the lane that decides which POS services it needs. |
| 4 |
* |
| 5 |
* @package WCPOS\WooCommercePOS |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WCPOS\WooCommercePOS\Services; |
| 9 |
|
| 10 |
use WCPOS\WooCommercePOS\Admin\Permalink; |
| 11 |
|
| 12 |
use const WCPOS\WooCommercePOS\SHORT_NAME; |
| 13 |
|
| 14 |
/** |
| 15 |
* One classifier for "what kind of request is this?", computed once per request. |
| 16 |
* |
| 17 |
* Why it exists: `Init::init_common()` used to construct every POS service on |
| 18 |
* every request. On a storefront page that was ~80 plugin files and 22 objects |
| 19 |
* for hooks that never fire there (measured 2026-09-03, see |
| 20 |
* .claude/research/2026-09-03-online-store-footprint.md and the |
| 21 |
* lazy-service-construction spec beside it). The lane is an OPTIMISATION, not a |
| 22 |
* correctness gate: order services are also constructed late, on the first |
| 23 |
* order write of any request (see `Init::ensure_order_services()`), so a lane |
| 24 |
* misclassified as storefront still gets every observer before an order is |
| 25 |
* touched. |
| 26 |
* |
| 27 |
* Detection runs on `init`, before `REST_REQUEST` is defined and before the |
| 28 |
* rewrite rules populate the POS query vars, so REST and the browser-loaded |
| 29 |
* POS routes are matched from the request path. WooCommerce's `wc-ajax` |
| 30 |
* endpoints (cart fragments, add to cart, the classic checkout) are shopper |
| 31 |
* traffic and stay on the storefront lane even though WooCommerce marks them |
| 32 |
* DOING_AJAX; admin-ajax.php is covered by `is_admin()`. |
| 33 |
*/ |
| 34 |
final class Request_Lane { |
| 35 |
public const ADMIN = 'admin'; |
| 36 |
public const CRON = 'cron'; |
| 37 |
public const CLI = 'cli'; |
| 38 |
public const REST = 'rest'; |
| 39 |
public const POS = 'pos'; |
| 40 |
public const STOREFRONT = 'storefront'; |
| 41 |
|
| 42 |
/** |
| 43 |
* Memoised lane for this request. |
| 44 |
* |
| 45 |
* @var string|null |
| 46 |
*/ |
| 47 |
private static ?string $lane = null; |
| 48 |
|
| 49 |
/** |
| 50 |
* The lane of the current request. |
| 51 |
* |
| 52 |
* @return string One of the class constants. |
| 53 |
*/ |
| 54 |
public static function current(): string { |
| 55 |
if ( null === self::$lane ) { |
| 56 |
/** |
| 57 |
* Filters the request lane. Hosts and tests can force one; the |
| 58 |
* default is {@see Request_Lane::detect()}. |
| 59 |
* |
| 60 |
* @param string $lane One of the Request_Lane constants. |
| 61 |
*/ |
| 62 |
self::$lane = (string) apply_filters( 'woocommerce_pos_request_lane', self::detect() ); |
| 63 |
} |
| 64 |
return self::$lane; |
| 65 |
} |
| 66 |
|
| 67 |
/** Whether the request is a plain shopper page: shop, product, cart, account, wc-ajax. */ |
| 68 |
public static function is_storefront(): bool { |
| 69 |
return self::STOREFRONT === self::current(); |
| 70 |
} |
| 71 |
|
| 72 |
/** |
| 73 |
* Forget the memoised lane. Tests only: the single PHPUnit process never |
| 74 |
* ends a request. |
| 75 |
* |
| 76 |
* @internal |
| 77 |
*/ |
| 78 |
public static function reset(): void { |
| 79 |
self::$lane = null; |
| 80 |
} |
| 81 |
|
| 82 |
/** |
| 83 |
* Detect the lane from the environment WordPress has established by `init`. |
| 84 |
* |
| 85 |
* @return string |
| 86 |
*/ |
| 87 |
private static function detect(): string { |
| 88 |
if ( \defined( 'WP_CLI' ) && WP_CLI ) { |
| 89 |
return self::CLI; |
| 90 |
} |
| 91 |
if ( wp_doing_cron() ) { |
| 92 |
return self::CRON; |
| 93 |
} |
| 94 |
if ( is_admin() ) { |
| 95 |
return self::ADMIN; |
| 96 |
} |
| 97 |
if ( \function_exists( 'woocommerce_pos_request' ) && woocommerce_pos_request() ) { |
| 98 |
return self::POS; |
| 99 |
} |
| 100 |
$uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only inspected for a prefix. |
| 101 |
if ( '' === $uri ) { |
| 102 |
return self::STOREFRONT; |
| 103 |
} |
| 104 |
// The raw ?wcpos=1 marker is not in the query vars yet at init. |
| 105 |
if ( 1 === preg_match( '#(?:\?|&)' . preg_quote( SHORT_NAME, '#' ) . '=1(?:&|$)#', $uri ) ) { |
| 106 |
return self::POS; |
| 107 |
} |
| 108 |
if ( false !== strpos( $uri, '/' . rest_get_url_prefix() . '/' ) || false !== strpos( $uri, 'rest_route=' ) ) { |
| 109 |
return self::REST; |
| 110 |
} |
| 111 |
$path = (string) wp_parse_url( $uri, PHP_URL_PATH ); |
| 112 |
$home_path = trailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ); |
| 113 |
if ( '/' !== $home_path && 0 === strpos( trailingslashit( $path ), $home_path ) ) { |
| 114 |
$path = (string) substr( $path, \strlen( untrailingslashit( $home_path ) ) ); |
| 115 |
} |
| 116 |
$slug = Permalink::get_slug(); |
| 117 |
if ( 1 === preg_match( '#^/(?:index\.php/)?(' . preg_quote( $slug, '#' ) . '|wcpos-[a-z-]+)(/|$)#i', $path ) ) { |
| 118 |
return self::POS; |
| 119 |
} |
| 120 |
return self::STOREFRONT; |
| 121 |
} |
| 122 |
} |
| 123 |
|