@tuturuuu/utils 0.7.0 → 0.9.0

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/src/currencies.ts CHANGED
@@ -66,9 +66,8 @@ export type CurrencyConfig = (typeof SUPPORTED_CURRENCIES)[number];
66
66
  * @returns The BCP 47 locale string for the currency
67
67
  */
68
68
  export function getCurrencyLocale(currency = 'VND'): string {
69
- const found = SUPPORTED_CURRENCIES.find(
70
- (c) => c.code === currency.toUpperCase()
71
- );
69
+ const code = currency.trim().toUpperCase();
70
+ const found = SUPPORTED_CURRENCIES.find((c) => c.code === code);
72
71
  return found?.locale ?? 'en-US';
73
72
  }
74
73
 
@@ -81,7 +80,30 @@ export function getCurrencyLocale(currency = 'VND'): string {
81
80
  export function isSupportedCurrency(
82
81
  currency: string
83
82
  ): currency is SupportedCurrency {
84
- return SUPPORTED_CURRENCIES.some((c) => c.code === currency.toUpperCase());
83
+ const code = currency.trim().toUpperCase();
84
+ return SUPPORTED_CURRENCIES.some((c) => c.code === code);
85
+ }
86
+
87
+ /**
88
+ * Normalize a currency code to the supported uppercase code set.
89
+ *
90
+ * @param value - The candidate currency code
91
+ * @param fallback - The fallback currency code when value is missing or unsupported
92
+ * @returns A supported currency code
93
+ */
94
+ export function resolveSupportedCurrency(
95
+ value: string | null | undefined,
96
+ fallback: string = 'USD'
97
+ ): SupportedCurrency {
98
+ const normalizedFallback = fallback.trim().toUpperCase();
99
+ const safeFallback = isSupportedCurrency(normalizedFallback)
100
+ ? normalizedFallback
101
+ : 'USD';
102
+ const normalizedValue = value?.trim().toUpperCase();
103
+
104
+ return normalizedValue && isSupportedCurrency(normalizedValue)
105
+ ? normalizedValue
106
+ : safeFallback;
85
107
  }
86
108
 
87
109
  /**
@@ -93,5 +115,6 @@ export function isSupportedCurrency(
93
115
  export function getCurrencyConfig(
94
116
  currency: string
95
117
  ): CurrencyConfig | undefined {
96
- return SUPPORTED_CURRENCIES.find((c) => c.code === currency.toUpperCase());
118
+ const code = currency.trim().toUpperCase();
119
+ return SUPPORTED_CURRENCIES.find((c) => c.code === code);
97
120
  }
@@ -0,0 +1,247 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ Effect,
4
+ forEachConcurrently,
5
+ fromDataError,
6
+ fromPromise,
7
+ runEffectAsResult,
8
+ serializeTuturuuuEffectError,
9
+ TuturuuuEffectError,
10
+ toTuturuuuEffectError,
11
+ withTuturuuuRetry,
12
+ withTuturuuuTimeout,
13
+ } from './effect';
14
+
15
+ describe('@tuturuuu/utils/effect', () => {
16
+ it('creates tagged Tuturuuu errors with metadata', () => {
17
+ const error = new TuturuuuEffectError({
18
+ code: 'TEST_ERROR',
19
+ message: 'Test failed',
20
+ status: 418,
21
+ context: { feature: 'effect' },
22
+ });
23
+
24
+ expect(error._tag).toBe('TuturuuuEffectError');
25
+ expect(error.message).toBe('Test failed');
26
+ expect(serializeTuturuuuEffectError(error)).toEqual({
27
+ _tag: 'TuturuuuEffectError',
28
+ code: 'TEST_ERROR',
29
+ message: 'Test failed',
30
+ status: 418,
31
+ context: { feature: 'effect' },
32
+ });
33
+ });
34
+
35
+ it('normalizes unknown errors with fallback metadata', () => {
36
+ const error = toTuturuuuEffectError(new Error('Boom'), {
37
+ code: 'FALLBACK',
38
+ message: 'Fallback message',
39
+ status: 500,
40
+ });
41
+
42
+ expect(error).toBeInstanceOf(TuturuuuEffectError);
43
+ expect(error.code).toBe('FALLBACK');
44
+ expect(error.message).toBe('Boom');
45
+ expect(error.status).toBe(500);
46
+ });
47
+
48
+ it('converts data/error style operations into successful effects', async () => {
49
+ const result = await Effect.runPromise(
50
+ fromDataError(
51
+ () => Promise.resolve({ data: { id: 'row-1' }, error: null }),
52
+ {
53
+ code: 'DATA_ERROR',
54
+ message: 'Data read failed',
55
+ }
56
+ )
57
+ );
58
+
59
+ expect(result).toEqual({ id: 'row-1' });
60
+ });
61
+
62
+ it('converts data/error style failures into typed errors', async () => {
63
+ const result = await runEffectAsResult(
64
+ fromDataError(
65
+ () =>
66
+ Promise.resolve({
67
+ data: null,
68
+ error: {
69
+ code: 'PGRST116',
70
+ message: 'No row found',
71
+ details: 'Expected one row',
72
+ },
73
+ }),
74
+ {
75
+ code: 'DATA_ERROR',
76
+ message: 'Data read failed',
77
+ }
78
+ )
79
+ );
80
+
81
+ expect(result).toEqual({
82
+ ok: false,
83
+ error: {
84
+ _tag: 'TuturuuuEffectError',
85
+ code: 'PGRST116',
86
+ message: 'No row found',
87
+ context: { details: 'Expected one row' },
88
+ },
89
+ });
90
+ });
91
+
92
+ it('converts rejected promises into typed errors', async () => {
93
+ const result = await runEffectAsResult(
94
+ fromPromise(() => Promise.reject(new Error('Network failed')), {
95
+ code: 'NETWORK_FAILED',
96
+ message: 'Request failed',
97
+ })
98
+ );
99
+
100
+ expect(result.ok).toBe(false);
101
+ if (!result.ok) {
102
+ expect(result.error).toMatchObject({
103
+ _tag: 'TuturuuuEffectError',
104
+ code: 'NETWORK_FAILED',
105
+ message: 'Network failed',
106
+ });
107
+ }
108
+ });
109
+
110
+ it('returns success and failure result shapes', async () => {
111
+ await expect(runEffectAsResult(Effect.succeed('ok'))).resolves.toEqual({
112
+ ok: true,
113
+ data: 'ok',
114
+ });
115
+
116
+ await expect(
117
+ runEffectAsResult(
118
+ Effect.fail(
119
+ new TuturuuuEffectError({
120
+ code: 'EXPECTED_FAILURE',
121
+ message: 'Expected failure',
122
+ })
123
+ )
124
+ )
125
+ ).resolves.toEqual({
126
+ ok: false,
127
+ error: {
128
+ _tag: 'TuturuuuEffectError',
129
+ code: 'EXPECTED_FAILURE',
130
+ message: 'Expected failure',
131
+ },
132
+ });
133
+ });
134
+
135
+ it('retries expected failures with a typed retry predicate', async () => {
136
+ let attempts = 0;
137
+ const program = Effect.flatMap(
138
+ Effect.sync(() => {
139
+ attempts += 1;
140
+ return attempts;
141
+ }),
142
+ (attempt) =>
143
+ attempt < 3
144
+ ? Effect.fail(
145
+ new TuturuuuEffectError({
146
+ code: 'TRANSIENT_FAILURE',
147
+ message: 'Try again',
148
+ })
149
+ )
150
+ : Effect.succeed('ready')
151
+ );
152
+
153
+ await expect(
154
+ Effect.runPromise(
155
+ withTuturuuuRetry(program, {
156
+ times: 2,
157
+ while: (error) => error.code === 'TRANSIENT_FAILURE',
158
+ })
159
+ )
160
+ ).resolves.toBe('ready');
161
+ expect(attempts).toBe(3);
162
+ });
163
+
164
+ it('does not retry when the retry predicate rejects the error', async () => {
165
+ let attempts = 0;
166
+
167
+ const result = await runEffectAsResult(
168
+ withTuturuuuRetry(
169
+ Effect.flatMap(
170
+ Effect.sync(() => {
171
+ attempts += 1;
172
+ }),
173
+ () =>
174
+ Effect.fail(
175
+ new TuturuuuEffectError({
176
+ code: 'PERMANENT_FAILURE',
177
+ message: 'Do not retry',
178
+ })
179
+ )
180
+ ),
181
+ {
182
+ times: 3,
183
+ while: (error) => error.code === 'TRANSIENT_FAILURE',
184
+ }
185
+ )
186
+ );
187
+
188
+ expect(attempts).toBe(1);
189
+ expect(result).toEqual({
190
+ ok: false,
191
+ error: {
192
+ _tag: 'TuturuuuEffectError',
193
+ code: 'PERMANENT_FAILURE',
194
+ message: 'Do not retry',
195
+ },
196
+ });
197
+ });
198
+
199
+ it('converts timeouts into Tuturuuu effect errors', async () => {
200
+ const result = await runEffectAsResult(
201
+ withTuturuuuTimeout(Effect.never, {
202
+ code: 'TIMED_OUT',
203
+ duration: 0,
204
+ message: 'Operation timed out',
205
+ context: { operation: 'test' },
206
+ })
207
+ );
208
+
209
+ expect(result).toEqual({
210
+ ok: false,
211
+ error: {
212
+ _tag: 'TuturuuuEffectError',
213
+ code: 'TIMED_OUT',
214
+ message: 'Operation timed out',
215
+ context: { operation: 'test' },
216
+ },
217
+ });
218
+ });
219
+
220
+ it('runs effectful collections with bounded concurrency', async () => {
221
+ let active = 0;
222
+ let maxActive = 0;
223
+
224
+ const result = await Effect.runPromise(
225
+ forEachConcurrently(
226
+ [1, 2, 3, 4],
227
+ (item) =>
228
+ Effect.promise(
229
+ () =>
230
+ new Promise<number>((resolve) => {
231
+ active += 1;
232
+ maxActive = Math.max(maxActive, active);
233
+
234
+ setTimeout(() => {
235
+ active -= 1;
236
+ resolve(item * 2);
237
+ }, 5);
238
+ })
239
+ ),
240
+ { concurrency: 2 }
241
+ )
242
+ );
243
+
244
+ expect(result).toEqual([2, 4, 6, 8]);
245
+ expect(maxActive).toBeLessThanOrEqual(2);
246
+ });
247
+ });
package/src/effect.ts ADDED
@@ -0,0 +1,259 @@
1
+ import {
2
+ Cause,
3
+ Context,
4
+ Data,
5
+ Duration,
6
+ Effect,
7
+ Either,
8
+ Exit,
9
+ Layer,
10
+ Option,
11
+ Schedule,
12
+ } from 'effect';
13
+
14
+ export {
15
+ Cause,
16
+ Context,
17
+ Data,
18
+ Duration,
19
+ Effect,
20
+ Either,
21
+ Layer,
22
+ Option,
23
+ Schedule,
24
+ };
25
+
26
+ export type TuturuuuEffectErrorContext = Record<string, unknown>;
27
+
28
+ export interface TuturuuuEffectErrorOptions {
29
+ readonly code: string;
30
+ readonly message: string;
31
+ readonly status?: number;
32
+ readonly cause?: unknown;
33
+ readonly context?: TuturuuuEffectErrorContext;
34
+ }
35
+
36
+ export class TuturuuuEffectError extends Data.TaggedError(
37
+ 'TuturuuuEffectError'
38
+ )<TuturuuuEffectErrorOptions> {}
39
+
40
+ export interface SerializedTuturuuuEffectError {
41
+ readonly _tag: 'TuturuuuEffectError';
42
+ readonly code: string;
43
+ readonly message: string;
44
+ readonly status?: number;
45
+ readonly context?: TuturuuuEffectErrorContext;
46
+ readonly cause?: {
47
+ readonly name?: string;
48
+ readonly message: string;
49
+ };
50
+ }
51
+
52
+ export type TuturuuuEffectResult<T> =
53
+ | {
54
+ readonly ok: true;
55
+ readonly data: T;
56
+ }
57
+ | {
58
+ readonly ok: false;
59
+ readonly error: SerializedTuturuuuEffectError;
60
+ };
61
+
62
+ export type DataErrorResult<TData, TError = unknown> = {
63
+ readonly data: TData;
64
+ readonly error: TError | null;
65
+ };
66
+
67
+ export interface TuturuuuEffectRetryOptions<E = unknown> {
68
+ readonly times?: number;
69
+ readonly delay?: Duration.DurationInput;
70
+ readonly while?: (error: E) => boolean;
71
+ }
72
+
73
+ export interface TuturuuuEffectTimeoutOptions
74
+ extends TuturuuuEffectErrorOptions {
75
+ readonly duration: Duration.DurationInput;
76
+ }
77
+
78
+ export interface TuturuuuEffectConcurrencyOptions {
79
+ readonly concurrency?: number | 'unbounded';
80
+ }
81
+
82
+ export const DEFAULT_TUTURUUU_EFFECT_CONCURRENCY = 4;
83
+
84
+ function isRecord(value: unknown): value is Record<string, unknown> {
85
+ return typeof value === 'object' && value !== null;
86
+ }
87
+
88
+ function readStringProperty(
89
+ source: Record<string, unknown> | undefined,
90
+ key: string
91
+ ) {
92
+ const value = source?.[key];
93
+ return typeof value === 'string' && value.trim() ? value : undefined;
94
+ }
95
+
96
+ function readNumberProperty(
97
+ source: Record<string, unknown> | undefined,
98
+ key: string
99
+ ) {
100
+ const value = source?.[key];
101
+ return typeof value === 'number' && Number.isFinite(value)
102
+ ? value
103
+ : undefined;
104
+ }
105
+
106
+ function buildContext(
107
+ source: Record<string, unknown> | undefined,
108
+ fallbackContext: TuturuuuEffectErrorContext | undefined
109
+ ) {
110
+ const context: TuturuuuEffectErrorContext = { ...(fallbackContext ?? {}) };
111
+ const details = readStringProperty(source, 'details');
112
+ const hint = readStringProperty(source, 'hint');
113
+
114
+ if (details) context.details = details;
115
+ if (hint) context.hint = hint;
116
+
117
+ return Object.keys(context).length > 0 ? context : undefined;
118
+ }
119
+
120
+ export function toTuturuuuEffectError(
121
+ error: unknown,
122
+ fallback: TuturuuuEffectErrorOptions
123
+ ) {
124
+ if (error instanceof TuturuuuEffectError) {
125
+ return error;
126
+ }
127
+
128
+ const source = isRecord(error) ? error : undefined;
129
+ const message =
130
+ error instanceof Error && error.message
131
+ ? error.message
132
+ : readStringProperty(source, 'message') || fallback.message;
133
+
134
+ return new TuturuuuEffectError({
135
+ code: readStringProperty(source, 'code') || fallback.code,
136
+ message,
137
+ status: readNumberProperty(source, 'status') ?? fallback.status,
138
+ cause: error,
139
+ context: buildContext(source, fallback.context),
140
+ });
141
+ }
142
+
143
+ export function serializeTuturuuuEffectError(
144
+ error: TuturuuuEffectError
145
+ ): SerializedTuturuuuEffectError {
146
+ return {
147
+ _tag: error._tag,
148
+ code: error.code,
149
+ message: error.message,
150
+ ...(error.status !== undefined ? { status: error.status } : {}),
151
+ ...(error.context ? { context: error.context } : {}),
152
+ ...(error.cause instanceof Error
153
+ ? {
154
+ cause: {
155
+ name: error.cause.name,
156
+ message: error.cause.message,
157
+ },
158
+ }
159
+ : {}),
160
+ };
161
+ }
162
+
163
+ export function fromPromise<T>(
164
+ evaluate: (signal: AbortSignal) => PromiseLike<T>,
165
+ fallback: TuturuuuEffectErrorOptions
166
+ ): Effect.Effect<T, TuturuuuEffectError> {
167
+ return Effect.tryPromise({
168
+ try: evaluate,
169
+ catch: (error) => toTuturuuuEffectError(error, fallback),
170
+ });
171
+ }
172
+
173
+ export function fromDataError<TData, TError = unknown>(
174
+ operation: (
175
+ signal: AbortSignal
176
+ ) => PromiseLike<DataErrorResult<TData, TError>>,
177
+ fallback: TuturuuuEffectErrorOptions
178
+ ): Effect.Effect<TData, TuturuuuEffectError> {
179
+ return Effect.flatMap(fromPromise(operation, fallback), (result) =>
180
+ result.error
181
+ ? Effect.fail(toTuturuuuEffectError(result.error, fallback))
182
+ : Effect.succeed(result.data)
183
+ );
184
+ }
185
+
186
+ export function withTuturuuuRetry<A, E, R>(
187
+ program: Effect.Effect<A, E, R>,
188
+ options: TuturuuuEffectRetryOptions<E> = {}
189
+ ): Effect.Effect<A, E, R> {
190
+ const times = Math.max(0, options.times ?? 2);
191
+
192
+ if (times === 0) {
193
+ return program;
194
+ }
195
+
196
+ return program.pipe(
197
+ Effect.retry({
198
+ ...(options.delay ? { schedule: Schedule.spaced(options.delay) } : {}),
199
+ times,
200
+ ...(options.while ? { while: options.while } : {}),
201
+ })
202
+ );
203
+ }
204
+
205
+ export function withTuturuuuTimeout<A, E, R>(
206
+ program: Effect.Effect<A, E, R>,
207
+ options: TuturuuuEffectTimeoutOptions
208
+ ): Effect.Effect<A, E | TuturuuuEffectError, R> {
209
+ return program.pipe(
210
+ Effect.timeoutFail({
211
+ duration: options.duration,
212
+ onTimeout: () =>
213
+ new TuturuuuEffectError({
214
+ code: options.code,
215
+ message: options.message,
216
+ status: options.status,
217
+ cause: options.cause,
218
+ context: options.context,
219
+ }),
220
+ })
221
+ );
222
+ }
223
+
224
+ export function forEachConcurrently<A, B, E, R>(
225
+ items: Iterable<A>,
226
+ evaluate: (item: A, index: number) => Effect.Effect<B, E, R>,
227
+ options: TuturuuuEffectConcurrencyOptions = {}
228
+ ): Effect.Effect<readonly B[], E, R> {
229
+ return Effect.forEach(items, evaluate, {
230
+ concurrency: options.concurrency ?? DEFAULT_TUTURUUU_EFFECT_CONCURRENCY,
231
+ });
232
+ }
233
+
234
+ export async function runEffectAsResult<T>(
235
+ program: Effect.Effect<T, TuturuuuEffectError, never>
236
+ ): Promise<TuturuuuEffectResult<T>> {
237
+ const exit = await Effect.runPromiseExit(program);
238
+
239
+ if (Exit.isSuccess(exit)) {
240
+ return {
241
+ ok: true,
242
+ data: exit.value,
243
+ };
244
+ }
245
+
246
+ const failure = Cause.failureOption(exit.cause);
247
+ const error = Option.isSome(failure)
248
+ ? failure.value
249
+ : new TuturuuuEffectError({
250
+ code: 'EFFECT_DEFECT',
251
+ message: Cause.pretty(exit.cause),
252
+ cause: exit.cause,
253
+ });
254
+
255
+ return {
256
+ ok: false,
257
+ error: serializeTuturuuuEffectError(error),
258
+ };
259
+ }
@@ -60,6 +60,10 @@ export const PRODUCTION_INTERNAL_APP_DOMAINS = [
60
60
  name: 'finance',
61
61
  url: 'https://finance.tuturuuu.com',
62
62
  },
63
+ {
64
+ name: 'infra',
65
+ url: 'https://infra.tuturuuu.com',
66
+ },
63
67
  {
64
68
  name: 'inventory',
65
69
  url: 'https://inventory.tuturuuu.com',
@@ -68,6 +72,10 @@ export const PRODUCTION_INTERNAL_APP_DOMAINS = [
68
72
  name: 'storefront',
69
73
  url: 'https://storefront.tuturuuu.com',
70
74
  },
75
+ {
76
+ name: 'tanstack-web',
77
+ url: 'https://tanstack.tuturuuu.com',
78
+ },
71
79
  {
72
80
  name: 'track',
73
81
  url: 'https://track.tuturuuu.com',
@@ -143,6 +151,10 @@ export const PORTLESS_INTERNAL_APP_DOMAINS = [
143
151
  name: 'finance',
144
152
  url: getTuturuuuPortlessAppOrigin('finance'),
145
153
  },
154
+ {
155
+ name: 'infra',
156
+ url: getTuturuuuPortlessAppOrigin('infra'),
157
+ },
146
158
  {
147
159
  name: 'inventory',
148
160
  url: getTuturuuuPortlessAppOrigin('inventory'),
@@ -151,6 +163,10 @@ export const PORTLESS_INTERNAL_APP_DOMAINS = [
151
163
  name: 'storefront',
152
164
  url: getTuturuuuPortlessAppOrigin('storefront'),
153
165
  },
166
+ {
167
+ name: 'tanstack-web',
168
+ url: getTuturuuuPortlessAppOrigin('tanstack-web'),
169
+ },
154
170
  {
155
171
  name: 'track',
156
172
  url: getTuturuuuPortlessAppOrigin('track'),
@@ -226,6 +242,10 @@ export const LOCALHOST_INTERNAL_APP_DOMAINS = [
226
242
  name: 'finance',
227
243
  url: 'http://localhost:7808',
228
244
  },
245
+ {
246
+ name: 'infra',
247
+ url: 'http://localhost:7823',
248
+ },
229
249
  {
230
250
  name: 'inventory',
231
251
  url: 'http://localhost:7815',
@@ -234,6 +254,10 @@ export const LOCALHOST_INTERNAL_APP_DOMAINS = [
234
254
  name: 'storefront',
235
255
  url: 'http://localhost:7822',
236
256
  },
257
+ {
258
+ name: 'tanstack-web',
259
+ url: 'http://localhost:7824',
260
+ },
237
261
  {
238
262
  name: 'track',
239
263
  url: 'http://localhost:7810',
@@ -370,12 +394,53 @@ function canUpgradeToRegisteredHttpsOrigin(value: URL, registeredUrl: URL) {
370
394
  }
371
395
 
372
396
  const PORTLESS_APP_HOSTS = new Set(Object.values(TUTURUUU_PORTLESS_APP_HOSTS));
397
+ const DEFAULT_PORTLESS_PROXY_PORT = '1355';
398
+
399
+ function normalizePortlessProxyPort(value: string | undefined) {
400
+ const trimmed = value?.trim();
401
+
402
+ if (!trimmed || !/^\d+$/u.test(trimmed)) {
403
+ return null;
404
+ }
405
+
406
+ const port = Number(trimmed);
407
+ return Number.isInteger(port) && port > 0 && port <= 65535
408
+ ? String(port)
409
+ : null;
410
+ }
411
+
412
+ function getPortFromUrl(value: string | undefined) {
413
+ if (!value?.trim()) {
414
+ return null;
415
+ }
416
+
417
+ try {
418
+ return normalizePortlessProxyPort(new URL(value).port);
419
+ } catch {
420
+ return null;
421
+ }
422
+ }
423
+
424
+ function getAllowedPortlessProxyPorts() {
425
+ return new Set(
426
+ [
427
+ DEFAULT_PORTLESS_PROXY_PORT,
428
+ normalizePortlessProxyPort(process.env.PORTLESS_PORT),
429
+ getPortFromUrl(process.env.PORTLESS_URL),
430
+ ].filter((port): port is string => Boolean(port))
431
+ );
432
+ }
433
+
434
+ function isAllowedPortlessProxyPort(port: string) {
435
+ return getAllowedPortlessProxyPorts().has(port);
436
+ }
373
437
 
374
438
  function canUseLocalPortlessProxyPort(value: URL, registeredUrl: URL) {
375
439
  return (
376
440
  value.protocol === registeredUrl.protocol &&
377
441
  !registeredUrl.port &&
378
442
  Boolean(value.port) &&
443
+ isAllowedPortlessProxyPort(value.port) &&
379
444
  PORTLESS_APP_HOSTS.has(registeredUrl.hostname)
380
445
  );
381
446
  }
@@ -447,16 +512,27 @@ function matchesAppDomainUrl(value: URL, domain: AppDomain) {
447
512
 
448
513
  function canonicalizeAppDomainUrl(value: URL, domain: AppDomain) {
449
514
  const registeredUrl = parseHttpUrl(domain.url);
515
+ const matchesDirectPortlessProxy =
516
+ registeredUrl &&
517
+ domain.kind === 'internal' &&
518
+ matchesPortlessProxyOrigin(value, registeredUrl);
519
+ const matchesPrefixedPortlessProxy =
520
+ registeredUrl &&
521
+ domain.kind === 'internal' &&
522
+ matchesPrefixedPortlessOrigin(value, registeredUrl);
450
523
 
451
524
  if (
452
525
  registeredUrl &&
453
526
  domain.kind === 'internal' &&
454
527
  (canUpgradeToRegisteredHttpsOrigin(value, registeredUrl) ||
455
- matchesPortlessProxyOrigin(value, registeredUrl))
528
+ matchesDirectPortlessProxy ||
529
+ matchesPrefixedPortlessProxy)
456
530
  ) {
457
531
  const canonicalUrl = new URL(value.toString());
458
532
  canonicalUrl.protocol = registeredUrl.protocol;
459
- canonicalUrl.hostname = registeredUrl.hostname;
533
+ canonicalUrl.hostname = matchesPrefixedPortlessProxy
534
+ ? value.hostname
535
+ : registeredUrl.hostname;
460
536
  canonicalUrl.port = registeredUrl.port;
461
537
  return serializeUrl(canonicalUrl);
462
538
  }