@ttsc/playground 0.20.1 → 0.21.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.
@@ -4,13 +4,12 @@
4
4
  //
5
5
  // Resolution algorithm (minimal):
6
6
  // - Bare specifier `typia/lib/X`:
7
- // try `typia/lib/X.js`, then `typia/lib/X/index.js`, then honor the
8
- // package's `exports` / `main` (read from <pkg>/package.json) before
9
- // giving up.
7
+ // honor the package's `exports` boundary when declared; otherwise try
8
+ // `typia/lib/X.js`, then `typia/lib/X/index.js`.
10
9
  // - Relative `./Y` / `../Y` (encountered when one pack module requires a
11
10
  // sibling): resolved against the caller's pack key.
12
- // - Bare `name` (no subpath): honors the package `main` field, falling back
13
- // to `name/index.js`.
11
+ // - Bare `name` (no subpath): honors package `exports`, then (only when
12
+ // exports is absent) `main`, falling back to `name/index.js`.
14
13
  //
15
14
  // Every successful load is cached so cyclic graphs settle. Module evaluation
16
15
  // wraps the source text in the standard CJS wrapper:
@@ -36,6 +35,15 @@ interface ISandboxRequireOptions {
36
35
  // neither Node's runtime nor an ESM evaluator.
37
36
  const ACTIVE_EXPORT_CONDITIONS = new Set(["require", "default"]);
38
37
 
38
+ type ExportTargetResolution =
39
+ | { type: "resolved"; key: string }
40
+ | { type: "blocked" }
41
+ | { type: "unresolved" };
42
+
43
+ class InvalidPackageTargetError extends Error {}
44
+ class InvalidPackageTargetLoadError extends Error {}
45
+ class InvalidPackageConfigError extends Error {}
46
+
39
47
  /**
40
48
  * Build a sandboxed `require` function over a runtime pack. Resolves typia /
41
49
  * `@typia/*` / randexp specifiers from the pack; throws on anything else so the
@@ -56,97 +64,104 @@ export function createSandboxRequire(
56
64
  return null;
57
65
  };
58
66
 
59
- const packageDeclaresExports = (pkg: string): boolean => {
60
- const key = `${pkg}/package.json`;
61
- if (!has(key)) return false;
67
+ const readPackageJson = (mount: string): IPackJson | null => {
68
+ const key = `${mount}/package.json`;
69
+ if (!has(key)) return null;
62
70
  try {
63
- return Object.prototype.hasOwnProperty.call(
64
- JSON.parse(pack[key]!) as IPackJson,
65
- "exports",
66
- );
71
+ const parsed = JSON.parse(pack[key]!) as unknown;
72
+ if (
73
+ parsed === null ||
74
+ typeof parsed !== "object" ||
75
+ Array.isArray(parsed)
76
+ ) {
77
+ throw new Error("package.json must contain an object");
78
+ }
79
+ return parsed as IPackJson;
67
80
  } catch {
68
- return false;
81
+ throw new InvalidPackageConfigError(
82
+ `invalid package configuration in ${key}`,
83
+ );
69
84
  }
70
85
  };
71
86
 
72
- const resolveExportTarget = (
73
- pkg: string,
74
- target: unknown,
75
- replacement = "",
76
- ): string | null => {
77
- const candidates = conditionalExportTargets(target);
78
- if (!candidates) return null;
79
- for (const candidate of candidates) {
80
- const resolved = `${pkg}/${stripDotSlash(
81
- candidate.split("*").join(replacement),
82
- )}`;
83
- if (has(resolved)) return resolved;
87
+ const packageDeclaresExports = (mount: string): boolean => {
88
+ const manifest = readPackageJson(mount);
89
+ return manifest?.exports !== null && manifest?.exports !== undefined;
90
+ };
91
+
92
+ const resolveLegacyFile = (candidate: string): string | null =>
93
+ tryPaths(
94
+ candidate,
95
+ `${candidate}.js`,
96
+ `${candidate}.cjs`,
97
+ `${candidate}.mjs`,
98
+ `${candidate}.json`,
99
+ );
100
+
101
+ const resolveLegacyIndex = (candidate: string): string | null =>
102
+ tryPaths(
103
+ `${candidate}/index.js`,
104
+ `${candidate}/index.cjs`,
105
+ `${candidate}/index.json`,
106
+ );
107
+
108
+ /** Resolve one legacy CommonJS file-or-directory candidate. */
109
+ const resolveLegacyPath = (candidate: string): string | null => {
110
+ const file = resolveLegacyFile(candidate);
111
+ if (file !== null) return file;
112
+
113
+ const manifest = readPackageJson(candidate);
114
+ if (typeof manifest?.main === "string" && manifest.main.length !== 0) {
115
+ const main = posixJoin(candidate, manifest.main);
116
+ if (main !== candidate) {
117
+ // Node's legacy tryPackage resolves the selected main as a file, then
118
+ // as that directory's index. It does not recursively interpret a
119
+ // second package.json below the selected main directory.
120
+ const resolvedMain =
121
+ resolveLegacyFile(main) ?? resolveLegacyIndex(main);
122
+ if (resolvedMain !== null) return resolvedMain;
123
+ }
84
124
  }
85
- return null;
125
+ return resolveLegacyIndex(candidate);
86
126
  };
87
127
 
88
128
  // Read package.json from pack and resolve via main/exports.
89
129
  const resolvePackageEntry = (
90
- pkg: string,
130
+ mount: string,
91
131
  subpath: string | null,
92
132
  ): string | null => {
93
- const pjKey = `${pkg}/package.json`;
94
- if (!has(pjKey)) return null;
95
- let pj: IPackJson;
96
- try {
97
- pj = JSON.parse(pack[pjKey]!) as IPackJson;
98
- } catch {
99
- return null;
133
+ const pj = readPackageJson(mount);
134
+ if (pj?.exports !== null && pj?.exports !== undefined) {
135
+ const resolution = resolvePackageExports(mount, pj.exports, subpath);
136
+ return resolution.type === "resolved" ? resolution.key : null;
100
137
  }
101
138
  if (subpath === null) {
102
- // bare "name": honor the root `exports` entry (string, subpath table
103
- // "." key, or bare condition map) → CJS target, else main, else index.
104
- if (pj.exports !== undefined)
105
- return resolveExportTarget(pkg, rootExportTarget(pj.exports));
106
- if (typeof pj.main === "string") {
107
- return tryPaths(
108
- `${pkg}/${stripDotSlash(pj.main)}`,
109
- `${pkg}/${stripDotSlash(pj.main)}.js`,
110
- `${pkg}/${stripDotSlash(pj.main)}.cjs`,
111
- `${pkg}/${stripDotSlash(pj.main)}.mjs`,
112
- `${pkg}/${stripDotSlash(pj.main)}.json`,
113
- `${pkg}/${stripDotSlash(pj.main)}/index.js`,
114
- `${pkg}/${stripDotSlash(pj.main)}/index.cjs`,
115
- );
116
- }
117
- return tryPaths(`${pkg}/index.js`, `${pkg}/index.cjs`);
139
+ return resolveLegacyPath(mount);
118
140
  }
119
- // Subpath: honor exact exports and general single-star patterns.
120
- const exportsAny = pj.exports;
121
- if (
122
- exportsAny !== undefined &&
123
- typeof exportsAny === "object" &&
124
- exportsAny !== null
125
- ) {
126
- const entries = exportsAny as Record<string, unknown>;
127
- // Exact match first.
128
- const exactKey = `./${subpath}`;
129
- if (
130
- Object.prototype.hasOwnProperty.call(entries, exactKey) &&
131
- !exactKey.includes("*") &&
132
- !exactKey.endsWith("/")
133
- )
134
- return resolveExportTarget(pkg, entries[exactKey]);
135
- // Node resolves the most-specific wildcard, not insertion order.
136
- const patterns = Object.entries(entries)
137
- .map(([pattern, target]) => ({
138
- pattern,
139
- replacement: exportPatternReplacement(pattern, exactKey),
140
- target,
141
- }))
142
- .filter(
143
- (entry): entry is typeof entry & { replacement: string } =>
144
- entry.replacement !== undefined,
145
- )
146
- .sort((a, b) => compareExportPatternKeys(a.pattern, b.pattern));
147
- for (const { replacement, target } of patterns) {
148
- return resolveExportTarget(pkg, target, replacement);
149
- }
141
+ return null;
142
+ };
143
+
144
+ /**
145
+ * Locate a package self-reference by walking from the calling module to its
146
+ * nearest manifest. Node enables self-reference only when that manifest has
147
+ * both the requested `name` and an `exports` field; the pack mount itself may
148
+ * be an npm alias whose key differs from `name`.
149
+ */
150
+ const selfReferenceMount = (
151
+ fromKey: string | null,
152
+ requestedPackage: string,
153
+ ): string | null => {
154
+ if (fromKey === null) return null;
155
+ const parts = dirname(fromKey).split("/").filter(Boolean);
156
+ for (let length = parts.length; length > 0; --length) {
157
+ const mount = parts.slice(0, length).join("/");
158
+ const manifest = readPackageJson(mount);
159
+ if (manifest === null) continue;
160
+ return manifest.name === requestedPackage &&
161
+ manifest.exports !== null &&
162
+ manifest.exports !== undefined
163
+ ? mount
164
+ : null;
150
165
  }
151
166
  return null;
152
167
  };
@@ -161,18 +176,14 @@ export function createSandboxRequire(
161
176
  if (!fromKey) return null;
162
177
  const baseDir = dirname(fromKey);
163
178
  const joined = posixJoin(baseDir, specifier);
164
- return tryPaths(
165
- joined,
166
- `${joined}.js`,
167
- `${joined}.cjs`,
168
- `${joined}.mjs`,
169
- `${joined}.json`,
170
- `${joined}/index.js`,
171
- `${joined}/index.cjs`,
172
- );
179
+ return resolveLegacyPath(joined);
173
180
  }
174
181
  // Bare specifier. Split into package name + subpath.
175
182
  const { pkg, subpath } = splitBareSpecifier(specifier);
183
+ const selfMount = selfReferenceMount(fromKey, pkg);
184
+ if (selfMount !== null) {
185
+ return resolvePackageEntry(selfMount, subpath);
186
+ }
176
187
  if (subpath === null) {
177
188
  return resolvePackageEntry(pkg, null);
178
189
  }
@@ -180,15 +191,7 @@ export function createSandboxRequire(
180
191
  // retain the historical packed-file fallback.
181
192
  if (packageDeclaresExports(pkg)) return resolvePackageEntry(pkg, subpath);
182
193
  // First try direct paths (covers packages that do not declare exports).
183
- const direct = tryPaths(
184
- `${pkg}/${subpath}`,
185
- `${pkg}/${subpath}.js`,
186
- `${pkg}/${subpath}.cjs`,
187
- `${pkg}/${subpath}.mjs`,
188
- `${pkg}/${subpath}.json`,
189
- `${pkg}/${subpath}/index.js`,
190
- `${pkg}/${subpath}/index.cjs`,
191
- );
194
+ const direct = resolveLegacyPath(`${pkg}/${subpath}`);
192
195
  if (direct) return direct;
193
196
  // Fall back to package.json exports map.
194
197
  return resolvePackageEntry(pkg, subpath);
@@ -208,7 +211,7 @@ export function createSandboxRequire(
208
211
  }
209
212
  const localRequire = (specifier: string): unknown => {
210
213
  const resolved = resolveSpecifier(specifier, key);
211
- if (!resolved) {
214
+ if (!resolved || !has(resolved)) {
212
215
  throw new Error(
213
216
  `require("${specifier}") is not available in the playground sandbox (from ${key})`,
214
217
  );
@@ -257,7 +260,7 @@ export function createSandboxRequire(
257
260
 
258
261
  return (specifier: string): unknown => {
259
262
  const resolved = resolveSpecifier(specifier, null);
260
- if (!resolved) {
263
+ if (!resolved || !has(resolved)) {
261
264
  throw new Error(
262
265
  `require("${specifier}") is not available in the playground sandbox`,
263
266
  );
@@ -302,52 +305,226 @@ function compareExportPatternKeys(left: string, right: string): number {
302
305
  }
303
306
 
304
307
  /**
305
- * Reduce a `package.json` `exports` field to the value describing its ROOT
306
- * (".") entry, ready for {@link conditionalExportTargets}. Node accepts three
307
- * valid root shapes and they must all resolve consistently:
308
- *
309
- * - A bare string target — `"exports": "./index.cjs"`;
310
- * - A subpath table keyed by "." — `{ ".": <target>, "./sub": ... }`;
311
- * - A bare condition map whose keys are all conditions, not subpaths — `{
312
- * "require": "./index.cjs", "default": "./index.cjs" }`.
308
+ * Resolve one package `exports` request without consulting pack existence.
313
309
  *
314
- * Node forbids mixing subpath keys with condition keys, so the presence of any
315
- * "."-prefixed key decides the interpretation: a subpath table exposes its root
316
- * as `exports["."]`, while a condition map is itself the root target. Returns
317
- * null when `exports` is absent, empty, or describes no root entry.
310
+ * Node chooses one target first and performs file loading afterward. Keeping
311
+ * those phases separate is essential: a valid first array target that names a
312
+ * missing file must fail at load time rather than fall through to a later
313
+ * target.
318
314
  */
319
- function rootExportTarget(exports: unknown): unknown {
320
- if (typeof exports === "string") return exports;
321
- if (!exports || typeof exports !== "object") return null;
322
- const obj = exports as Record<string, unknown>;
323
- const keys = Object.keys(obj);
324
- if (keys.length === 0) return null;
325
- const hasSubpathKey = keys.some((k) => k === "." || k.startsWith("./"));
326
- return hasSubpathKey ? obj["."] : obj;
315
+ function resolvePackageExports(
316
+ mount: string,
317
+ exportsField: unknown,
318
+ subpath: string | null,
319
+ ): ExportTargetResolution {
320
+ let target: unknown;
321
+ if (
322
+ exportsField !== null &&
323
+ typeof exportsField === "object" &&
324
+ !Array.isArray(exportsField)
325
+ ) {
326
+ const entries = exportsField as Record<string, unknown>;
327
+ const kind = classifyExportsObject(mount, entries);
328
+ if (kind === "conditions") {
329
+ if (subpath !== null) return { type: "unresolved" };
330
+ target = entries;
331
+ } else {
332
+ const request = subpath === null ? "." : `./${subpath}`;
333
+ if (
334
+ Object.prototype.hasOwnProperty.call(entries, request) &&
335
+ !request.includes("*") &&
336
+ !request.endsWith("/")
337
+ ) {
338
+ target = entries[request];
339
+ } else {
340
+ const patterns = Object.entries(entries)
341
+ .map(([pattern, candidate]) => ({
342
+ pattern,
343
+ replacement: exportPatternReplacement(pattern, request),
344
+ target: candidate,
345
+ }))
346
+ .filter(
347
+ (entry): entry is typeof entry & { replacement: string } =>
348
+ entry.replacement !== undefined,
349
+ )
350
+ .sort((a, b) => compareExportPatternKeys(a.pattern, b.pattern));
351
+ const selected = patterns[0];
352
+ if (selected === undefined) return { type: "unresolved" };
353
+ return resolvePackageTarget(
354
+ mount,
355
+ selected.target,
356
+ selected.replacement,
357
+ );
358
+ }
359
+ }
360
+ } else {
361
+ if (subpath !== null) return { type: "unresolved" };
362
+ target = exportsField;
363
+ }
364
+ return resolvePackageTarget(mount, target, "");
327
365
  }
328
366
 
329
- function conditionalExportTargets(value: unknown): string[] | null {
330
- if (typeof value === "string") return [value];
331
- if (Array.isArray(value)) {
332
- const candidates: string[] = [];
333
- for (const target of value) {
334
- const resolved = conditionalExportTargets(target);
335
- if (resolved) candidates.push(...resolved);
367
+ /** Classify and validate a top-level exports object. */
368
+ function classifyExportsObject(
369
+ mount: string,
370
+ entries: Record<string, unknown>,
371
+ ): "subpaths" | "conditions" {
372
+ const keys = Object.keys(entries);
373
+ const subpathKeys = keys.filter((key) => key.startsWith("."));
374
+ if (subpathKeys.length !== 0 && subpathKeys.length !== keys.length) {
375
+ throw new InvalidPackageConfigError(
376
+ `invalid package configuration for ${mount}: exports cannot mix subpath and condition keys`,
377
+ );
378
+ }
379
+ if (subpathKeys.length !== 0) {
380
+ if (subpathKeys.some((key) => key !== "." && !key.startsWith("./"))) {
381
+ throw new InvalidPackageConfigError(
382
+ `invalid package configuration for ${mount}: invalid exports subpath key`,
383
+ );
384
+ }
385
+ return "subpaths";
386
+ }
387
+ validateConditionKeys(mount, keys);
388
+ return "conditions";
389
+ }
390
+
391
+ /** Resolve a string, array, conditional object, or null exports target. */
392
+ function resolvePackageTarget(
393
+ mount: string,
394
+ target: unknown,
395
+ replacement: string,
396
+ ): ExportTargetResolution {
397
+ if (typeof target === "string") {
398
+ const substituted = target.split("*").join(replacement);
399
+ return {
400
+ type: "resolved",
401
+ key: resolvePackageTargetKey(mount, substituted),
402
+ };
403
+ }
404
+ if (target === null) return { type: "blocked" };
405
+ if (Array.isArray(target)) {
406
+ if (target.length === 0) return { type: "blocked" };
407
+ let lastInvalid: InvalidPackageTargetError | undefined;
408
+ let lastBlocked = false;
409
+ for (const candidate of target) {
410
+ try {
411
+ const resolution = resolvePackageTarget(mount, candidate, replacement);
412
+ if (resolution.type === "blocked") {
413
+ lastBlocked = true;
414
+ lastInvalid = undefined;
415
+ } else if (resolution.type === "resolved") {
416
+ // File existence is a later phase and cannot trigger fallback.
417
+ return resolution;
418
+ }
419
+ } catch (error) {
420
+ if (!(error instanceof InvalidPackageTargetError)) throw error;
421
+ lastInvalid = error;
422
+ lastBlocked = false;
423
+ }
336
424
  }
337
- return candidates.length === 0 ? null : candidates;
425
+ if (lastInvalid !== undefined) throw lastInvalid;
426
+ if (lastBlocked) return { type: "blocked" };
427
+ return { type: "unresolved" };
338
428
  }
339
- if (value && typeof value === "object") {
340
- const obj = value as Record<string, unknown>;
341
- for (const [condition, target] of Object.entries(obj)) {
429
+ if (target !== null && typeof target === "object") {
430
+ const conditions = target as Record<string, unknown>;
431
+ const keys = Object.keys(conditions);
432
+ validateConditionKeys(mount, keys);
433
+ for (const [condition, candidate] of Object.entries(conditions)) {
342
434
  if (!ACTIVE_EXPORT_CONDITIONS.has(condition)) continue;
343
- return conditionalExportTargets(target);
435
+ const resolution = resolvePackageTarget(mount, candidate, replacement);
436
+ if (resolution.type === "unresolved") continue;
437
+ return resolution;
344
438
  }
439
+ return { type: "unresolved" };
440
+ }
441
+ throw new InvalidPackageTargetError(
442
+ `invalid package target for ${mount}: expected a relative ./ target`,
443
+ );
444
+ }
445
+
446
+ /** Reject integer-like condition keys, whose enumeration order is ambiguous. */
447
+ function validateConditionKeys(mount: string, keys: string[]): void {
448
+ if (keys.some(isArrayIndexKey)) {
449
+ throw new InvalidPackageConfigError(
450
+ `invalid package configuration for ${mount}: numeric exports condition keys are not allowed`,
451
+ );
345
452
  }
346
- return null;
347
453
  }
348
454
 
349
- function stripDotSlash(p: string): string {
350
- return p.startsWith("./") ? p.slice(2) : p;
455
+ function isArrayIndexKey(key: string): boolean {
456
+ if (!/^(?:0|[1-9]\d*)$/.test(key)) return false;
457
+ const value = Number(key);
458
+ return value >= 0 && value < 0xffffffff && String(value) === key;
459
+ }
460
+
461
+ /**
462
+ * Resolve one URL-like package target into a normalized pack key.
463
+ *
464
+ * Targets are URL-like package-relative paths. Dot segments, `node_modules`,
465
+ * and encoded path separators cannot escape or reinterpret the mount. URL
466
+ * query/hash components do not participate in filesystem lookup, and pathname
467
+ * percent escapes are decoded exactly once.
468
+ */
469
+ function resolvePackageTargetKey(mount: string, target: string): string {
470
+ if (!target.startsWith("./")) {
471
+ throw new InvalidPackageTargetError(
472
+ `invalid package target for ${mount}: ${JSON.stringify(target)}`,
473
+ );
474
+ }
475
+ const pathnameTarget = target.split(/[?#]/, 1)[0]!;
476
+ if (/%(?:2f|5c)/i.test(pathnameTarget)) {
477
+ throw new InvalidPackageTargetLoadError(
478
+ `invalid module specifier for ${mount}: ${JSON.stringify(target)}`,
479
+ );
480
+ }
481
+ for (const rawSegment of pathnameTarget.slice(2).split(/[\\/]/)) {
482
+ let decoded: string;
483
+ try {
484
+ decoded = decodeURIComponent(rawSegment);
485
+ } catch {
486
+ throw new InvalidPackageTargetLoadError(
487
+ `invalid module specifier for ${mount}: ${JSON.stringify(target)}`,
488
+ );
489
+ }
490
+ const normalized = decoded.toLowerCase();
491
+ if (
492
+ normalized === "." ||
493
+ normalized === ".." ||
494
+ normalized === "node_modules" ||
495
+ decoded.includes("/") ||
496
+ decoded.includes("\\")
497
+ ) {
498
+ throw new InvalidPackageTargetError(
499
+ `invalid package target for ${mount}: ${JSON.stringify(target)}`,
500
+ );
501
+ }
502
+ }
503
+ try {
504
+ const base = new URL(`https://sandbox.invalid/${mount}/`);
505
+ const resolved = new URL(target, base);
506
+ const basePath = decodeURIComponent(base.pathname);
507
+ const resolvedPath = decodeURIComponent(resolved.pathname).replace(
508
+ /\/+/g,
509
+ "/",
510
+ );
511
+ if (!resolvedPath.startsWith(basePath)) {
512
+ throw new InvalidPackageTargetError(
513
+ `invalid package target for ${mount}: ${JSON.stringify(target)}`,
514
+ );
515
+ }
516
+ const relative = resolvedPath.slice(basePath.length);
517
+ return `${mount}/${relative}`;
518
+ } catch (error) {
519
+ if (
520
+ error instanceof InvalidPackageTargetError ||
521
+ error instanceof InvalidPackageTargetLoadError
522
+ )
523
+ throw error;
524
+ throw new InvalidPackageTargetLoadError(
525
+ `invalid module specifier for ${mount}: ${JSON.stringify(target)}`,
526
+ );
527
+ }
351
528
  }
352
529
 
353
530
  function dirname(p: string): string {
@@ -22,6 +22,19 @@ export interface IPlaygroundDependencyInstallOptions {
22
22
  ignoredPackages?: Iterable<string>;
23
23
  /** Safety cap: error out after installing this many packages. */
24
24
  maxPackages?: number;
25
+ /**
26
+ * Maximum compressed bytes accepted for one npm tarball.
27
+ *
28
+ * Defaults to 16 MiB and is enforced while streaming, independent of the
29
+ * response's `Content-Length`.
30
+ */
31
+ maxTarballBytes?: number;
32
+ /**
33
+ * Maximum expanded tar bytes accepted for one npm package.
34
+ *
35
+ * Defaults to 64 MiB and is enforced while gzip output is streamed.
36
+ */
37
+ maxUnpackedBytes?: number;
25
38
  /** Aborts the install when triggered. */
26
39
  signal?: AbortSignal;
27
40
  /** Fires for each phase transition during the install. */