tjs-lang 0.7.7 → 0.7.8

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.
Files changed (59) hide show
  1. package/CLAUDE.md +90 -33
  2. package/bin/docs.js +4 -1
  3. package/demo/docs.json +45 -11
  4. package/demo/src/examples.test.ts +1 -0
  5. package/demo/src/imports.test.ts +16 -4
  6. package/demo/src/imports.ts +60 -15
  7. package/demo/src/playground-shared.ts +9 -8
  8. package/demo/src/tfs-worker.js +205 -147
  9. package/demo/src/tjs-playground.ts +34 -10
  10. package/demo/src/ts-playground.ts +24 -8
  11. package/dist/index.js +118 -101
  12. package/dist/index.js.map +4 -4
  13. package/dist/src/lang/bool-coercion.d.ts +50 -0
  14. package/dist/src/lang/docs.d.ts +31 -6
  15. package/dist/src/lang/linter.d.ts +8 -0
  16. package/dist/src/lang/parser-transforms.d.ts +18 -0
  17. package/dist/src/lang/parser-types.d.ts +2 -0
  18. package/dist/src/lang/parser.d.ts +3 -0
  19. package/dist/src/lang/runtime.d.ts +34 -0
  20. package/dist/src/lang/types.d.ts +9 -1
  21. package/dist/src/rbac/index.d.ts +1 -1
  22. package/dist/src/vm/runtime.d.ts +1 -1
  23. package/dist/tjs-eval.js +38 -36
  24. package/dist/tjs-eval.js.map +4 -4
  25. package/dist/tjs-from-ts.js +20 -20
  26. package/dist/tjs-from-ts.js.map +3 -3
  27. package/dist/tjs-lang.js +85 -83
  28. package/dist/tjs-lang.js.map +4 -4
  29. package/dist/tjs-vm.js +47 -45
  30. package/dist/tjs-vm.js.map +4 -4
  31. package/llms.txt +79 -0
  32. package/package.json +3 -2
  33. package/src/cli/commands/convert.test.ts +16 -21
  34. package/src/lang/bool-coercion.test.ts +203 -0
  35. package/src/lang/bool-coercion.ts +314 -0
  36. package/src/lang/codegen.test.ts +137 -0
  37. package/src/lang/docs.test.ts +328 -1
  38. package/src/lang/docs.ts +424 -24
  39. package/src/lang/emitters/ast.ts +11 -12
  40. package/src/lang/emitters/dts.test.ts +41 -0
  41. package/src/lang/emitters/dts.ts +9 -0
  42. package/src/lang/emitters/js-tests.ts +9 -4
  43. package/src/lang/emitters/js.ts +182 -2
  44. package/src/lang/inference.ts +54 -0
  45. package/src/lang/linter.test.ts +104 -1
  46. package/src/lang/linter.ts +124 -1
  47. package/src/lang/parser-params.ts +31 -0
  48. package/src/lang/parser-transforms.ts +304 -0
  49. package/src/lang/parser-types.ts +2 -0
  50. package/src/lang/parser.test.ts +73 -1
  51. package/src/lang/parser.ts +34 -1
  52. package/src/lang/runtime.ts +98 -0
  53. package/src/lang/types.ts +6 -0
  54. package/src/rbac/index.ts +2 -2
  55. package/src/rbac/rules.tjs.d.ts +9 -0
  56. package/src/vm/atoms/batteries.ts +2 -2
  57. package/src/vm/runtime.ts +10 -3
  58. package/dist/src/rbac/rules.d.ts +0 -184
  59. package/src/rbac/rules.js +0 -338
@@ -1,16 +1,29 @@
1
1
  /**
2
- * Playground Import Resolver — TFS Edition
2
+ * Playground Import Resolver
3
3
  *
4
- * Rewrites bare import specifiers to /tfs/ URLs.
5
- * The TFS service worker handles CDN resolution and caching.
4
+ * Rewrites bare import specifiers to /tfs/<spec> URLs. The TFS service
5
+ * worker (demo/src/tfs-worker.js) intercepts those, proxies to esm.sh,
6
+ * and rewrites esm.sh's relative paths to absolute esm.sh URLs.
6
7
  *
7
8
  * Flow:
8
- * import { x } from 'tosijs@1.3.11'
9
- * → rewritten to: import { x } from '/tfs/tosijs@1.3.11'
10
- * → service worker intercepts /tfs/tosijs@1.3.11
11
- * → fetches from jsdelivr CDN, caches, returns ESM
9
+ * import { x } from 'tosijs'
10
+ * → import { x } from '/tfs/tosijs'
11
+ * → SW intercepts, fetches https://esm.sh/tosijs
12
+ * → SW rewrites response to use absolute esm.sh URLs for sub-modules
13
+ * → browser fetches sub-modules directly from esm.sh (cross-origin,
14
+ * bypasses this SW), enabling peer-dep dedup
12
15
  *
13
- * No import maps needed.
16
+ * The /tfs/ indirection — instead of writing esm.sh URLs directly into
17
+ * user code — exists so the SW can:
18
+ * - cache responses across page loads
19
+ * - intercept future virtual-module paths (/vmod/...) for IDE features
20
+ * - swap the CDN backend without changing emitted code
21
+ *
22
+ * For this to work, the playground iframe must be SAME-ORIGIN with the
23
+ * page (so the SW controls it). See tjs-playground.ts: the iframe loads
24
+ * from /iframe/<sessionId>, which the SW serves from a postMessage-fed
25
+ * in-memory store. blob: URLs do NOT work — Chrome SWs don't intercept
26
+ * fetches from sandboxed blob iframes.
14
27
  */
15
28
 
16
29
  /**
@@ -33,19 +46,16 @@ export function extractImports(source: string): string[] {
33
46
  }
34
47
 
35
48
  /**
36
- * Rewrite bare import specifiers to absolute /tfs/ URLs.
37
- * Uses location.origin so it works in blob iframes.
38
- * Skips relative (./) and absolute (/) paths.
49
+ * Rewrite bare import specifiers to /tfs/<spec> URLs.
50
+ * Skips relative (./), absolute (/), and already-absolute http(s):// paths.
39
51
  *
40
52
  * Before: import { tosi } from 'tosijs'
41
- * After: import { tosi } from 'http://localhost:8699/tfs/tosijs'
53
+ * After: import { tosi } from '/tfs/tosijs'
42
54
  */
43
55
  export function rewriteImports(source: string): string {
44
- const origin = typeof location !== 'undefined' ? location.origin : ''
45
56
  return source.replace(
46
57
  /((?:import|export)\s+(?:[\w\s{},*]+\s+from\s+)?)(['"])([^'"]+)\2/g,
47
58
  (match, prefix, quote, specifier) => {
48
- // Skip relative and absolute paths — only rewrite bare specifiers
49
59
  if (
50
60
  specifier.startsWith('.') ||
51
61
  specifier.startsWith('/') ||
@@ -54,7 +64,7 @@ export function rewriteImports(source: string): string {
54
64
  ) {
55
65
  return match
56
66
  }
57
- return `${prefix}${quote}${origin}/tfs/${specifier}${quote}`
67
+ return `${prefix}${quote}/tfs/${specifier}${quote}`
58
68
  }
59
69
  )
60
70
  }
@@ -85,3 +95,38 @@ export async function registerTFS(): Promise<boolean> {
85
95
  return false
86
96
  }
87
97
  }
98
+
99
+ /**
100
+ * Register an iframe's HTML with the SW under a session ID, then resolve
101
+ * once the SW confirms registration. The caller can then set the iframe's
102
+ * src to `/iframe/<sessionId>` and the SW will serve the registered HTML.
103
+ *
104
+ * Resolves to false if the SW isn't active (caller should fall back to
105
+ * blob: URL — the iframe will work but won't have SW interception).
106
+ */
107
+ export async function registerIframeContent(
108
+ sessionId: string,
109
+ html: string
110
+ ): Promise<boolean> {
111
+ const sw = navigator.serviceWorker?.controller
112
+ if (!sw) return false
113
+
114
+ return new Promise<boolean>((resolve) => {
115
+ const channel = new MessageChannel()
116
+ let settled = false
117
+ const settle = (ok: boolean) => {
118
+ if (settled) return
119
+ settled = true
120
+ channel.port1.close()
121
+ resolve(ok)
122
+ }
123
+ channel.port1.onmessage = (e) => {
124
+ settle(e.data?.type === 'iframe-registered')
125
+ }
126
+ // Safety: if the SW never replies, don't hang the playground forever
127
+ setTimeout(() => settle(false), 500)
128
+ sw.postMessage({ type: 'register-iframe', sessionId, html }, [
129
+ channel.port2,
130
+ ])
131
+ })
132
+ }
@@ -272,16 +272,17 @@ export function renderTestResults(
272
272
  const passed = tests.filter((t: any) => t.passed).length
273
273
  const failed = tests.filter((t: any) => !t.passed).length
274
274
 
275
- // Set gutter markers for ALL tests (pass and fail)
276
- const testsWithLines = tests.filter((t: any) => t.line)
277
- if (testsWithLines.length > 0) {
275
+ // Mark only FAILED tests in the editor. Passing tests appear in the
276
+ // results panel and don't need an underline — `severity: 'info'` was
277
+ // rendering as a grey squiggle on every passing test's line, which is
278
+ // visual noise.
279
+ const failedWithLines = tests.filter((t: any) => t.line && !t.passed)
280
+ if (failedWithLines.length > 0) {
278
281
  editor.setMarkers(
279
- testsWithLines.map((t: any) => ({
282
+ failedWithLines.map((t: any) => ({
280
283
  line: t.line,
281
- message: t.passed
282
- ? `✓ ${t.description}`
283
- : `✗ ${t.description}: ${t.error || 'failed'}`,
284
- severity: t.passed ? ('info' as const) : ('error' as const),
284
+ message: `✗ ${t.description}: ${t.error || 'failed'}`,
285
+ severity: 'error' as const,
285
286
  }))
286
287
  )
287
288
  } else {
@@ -1,163 +1,231 @@
1
1
  /**
2
2
  * TFS — TJS File System Service Worker
3
3
  *
4
- * Intercepts /tfs/ requests and resolves them to CDN modules.
5
- * Handles caching, version resolution, and ESM serving.
4
+ * Acts as a tiny in-browser server for the playground iframe:
6
5
  *
7
- * URL format: /tfs/package@version/subpath
8
- * /tfs/tosijs@1.3.11 jsdelivr CDN
9
- * /tfs/tosijs@latest → jsdelivr CDN (latest)
10
- * /tfs/lodash-es@4.17.21 → jsdelivr CDN
11
- * /tfs/@scope/pkg@1.0.0 → jsdelivr CDN (scoped)
6
+ * /iframe/<sessionId> → playground HTML registered via postMessage
7
+ * /tfs/<spec> CDN module (JSDelivr by default, esm.sh for
8
+ * packages that need peer-dep dedup like React)
12
9
  *
13
- * If no version specified, defaults to @latest.
10
+ * The iframe is loaded from a same-origin /iframe/<id> URL (instead of
11
+ * a blob: URL) so the SW controls it. Every fetch from inside the iframe
12
+ * — module imports, future virtual-module reads, future fetch() mocks —
13
+ * goes through this SW.
14
+ *
15
+ * URL format for /tfs/:
16
+ * /tfs/tosijs → JSDelivr `/+esm`
17
+ * /tfs/lodash-es/debounce → JSDelivr `/+esm`
18
+ * /tfs/react → esm.sh (peer-dep dedup with react-dom)
19
+ * /tfs/react-dom/client → esm.sh
20
+ * /tfs/@scope/pkg@1.0.0 → JSDelivr `/+esm`
21
+ * /tfs/__status → cache stats
22
+ * /tfs/__clear → drop the cache
14
23
  */
15
24
 
16
- const CACHE_NAME = 'tfs-v1'
17
- const CDN_BASE = 'https://cdn.jsdelivr.net/npm'
25
+ const SW_VERSION = 'tfs-v4-mixed-cdn'
26
+ const CACHE_NAME = SW_VERSION
27
+
28
+ // Default CDN. JSDelivr's `/+esm` returns a self-contained Rollup-bundled
29
+ // ESM module — works cleanly for ESM-native packages (tosijs, lodash-es)
30
+ // AND CJS packages (most things). Bundles dependencies inline.
31
+ const JSDELIVR = 'https://cdn.jsdelivr.net/npm'
32
+
33
+ // esm.sh override for packages with tricky peer-dependency requirements.
34
+ // React + react-dom can't both bundle React inline (the dispatcher state
35
+ // is module-scoped — two copies → useState() crashes with null). esm.sh
36
+ // builds these with `external: react` so the user's `import 'react'`
37
+ // and react-dom's internal react reference resolve to the SAME URL.
38
+ //
39
+ // Caveat: esm.sh wraps SOME ESM-native packages as CJS-with-default-only,
40
+ // breaking named imports. Tested: tosijs is broken, lodash-es is broken.
41
+ // Only use it where the peer-dep dedup matters more than named imports.
42
+ const ESM_SH = 'https://esm.sh'
43
+ const ESM_SH_PACKAGES = new Set(['react', 'react-dom'])
44
+
45
+ // Explicit per-import CDN hints (first path segment in the spec).
46
+ // Lets users force a specific CDN when default routing picks the wrong one.
47
+ //
48
+ // import x from 'jsdelivr/tosijs@1.6' → JSDelivr
49
+ // import x from 'esmsh/react@18' → esm.sh
50
+ // import x from 'unpkg/preact' → UNPKG
51
+ // import x from 'github/user/repo@main/file' → esm.sh's /gh/ route
52
+ //
53
+ // Hint name `esm` would conflict with the popular `esm` npm package, so we
54
+ // use `esmsh`. `jsdelivr`, `unpkg`, `github` are not taken as bare top-level
55
+ // npm packages.
56
+ const CDN_HINTS = {
57
+ jsdelivr: (rest) => `${JSDELIVR}/${rest}/+esm`,
58
+ esmsh: (rest) => `${ESM_SH}/${rest}`,
59
+ unpkg: (rest) => `https://unpkg.com/${rest}?module`,
60
+ github: (rest) => `${ESM_SH}/gh/${rest}`,
61
+ }
62
+
63
+ // In-memory store of iframe HTML by session ID. Populated via postMessage
64
+ // from the playground when running code; consumed when the iframe fetches
65
+ // /iframe/<sessionId>. Lost on SW restart, which is fine — the playground
66
+ // re-registers each run.
67
+ const iframeContents = new Map()
18
68
 
19
69
  self.addEventListener('install', () => {
70
+ console.log(`[TFS] installing ${SW_VERSION}`)
20
71
  self.skipWaiting()
21
72
  })
22
73
 
23
74
  self.addEventListener('activate', (event) => {
24
- event.waitUntil(clients.claim())
75
+ console.log(`[TFS] activating ${SW_VERSION}`)
76
+ event.waitUntil(
77
+ (async () => {
78
+ // Drop caches from previous SW versions
79
+ const keys = await caches.keys()
80
+ await Promise.all(
81
+ keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
82
+ )
83
+ await clients.claim()
84
+ })()
85
+ )
25
86
  })
26
87
 
27
- /**
28
- * Parse a TFS path into package name, version, and subpath.
29
- *
30
- * Examples:
31
- * tosijs@1.3.11 → { name: 'tosijs', version: '1.3.11', subpath: '' }
32
- * tosijs → { name: 'tosijs', version: 'latest', subpath: '' }
33
- * tosijs@1.3.11/utils → { name: 'tosijs', version: '1.3.11', subpath: '/utils' }
34
- * @scope/pkg@1.0.0 → { name: '@scope/pkg', version: '1.0.0', subpath: '' }
35
- * @scope/pkg@1.0.0/sub → { name: '@scope/pkg', version: '1.0.0', subpath: '/sub' }
36
- */
37
- function parseTfsPath(path) {
38
- // Scoped packages: @scope/name@version/subpath
39
- if (path.startsWith('@')) {
40
- const match = path.match(/^(@[^/]+\/[^/@]+)(?:@([^/]+))?(\/.*)?$/)
41
- if (match) {
42
- return {
43
- name: match[1],
44
- version: match[2] || 'latest',
45
- subpath: match[3] || '',
46
- }
47
- }
48
- }
88
+ // Receive iframe HTML registrations from the playground.
89
+ // Replies on event.ports[0] (if provided) so the playground can await
90
+ // the registration before setting iframe.src.
91
+ self.addEventListener('message', (event) => {
92
+ const data = event.data
93
+ if (!data || typeof data !== 'object') return
49
94
 
50
- // Regular packages: name@version/subpath
51
- const match = path.match(/^([^/@]+)(?:@([^/]+))?(\/.*)?$/)
52
- if (match) {
53
- return {
54
- name: match[1],
55
- version: match[2] || 'latest',
56
- subpath: match[3] || '',
95
+ if (data.type === 'register-iframe') {
96
+ iframeContents.set(data.sessionId, data.html)
97
+ if (event.ports && event.ports[0]) {
98
+ event.ports[0].postMessage({ type: 'iframe-registered' })
57
99
  }
100
+ } else if (data.type === 'unregister-iframe') {
101
+ iframeContents.delete(data.sessionId)
58
102
  }
59
-
60
- return null
61
- }
62
-
63
- /**
64
- * Resolve the ESM entry point for a package.
65
- * Fetches package.json and checks exports → module → main.
66
- */
67
- async function resolveEntryPoint(name, version) {
68
- const pkgUrl = `${CDN_BASE}/${name}@${version}/package.json`
69
- const res = await fetch(pkgUrl)
70
- if (!res.ok) return null
71
-
72
- const pkg = await res.json()
73
-
74
- // Check exports field first (modern ESM)
75
- // exports can be { ".": { import: "..." } } or { import: "..." } directly
76
- if (pkg.exports) {
77
- const dot = pkg.exports['.'] ?? pkg.exports
78
- if (typeof dot === 'string') return dot
79
- if (dot?.import)
80
- return typeof dot.import === 'string' ? dot.import : dot.import.default
81
- if (dot?.default) return dot.default
82
- }
83
-
84
- // module field (bundler convention)
85
- if (pkg.module) return pkg.module
86
-
87
- // main field (fallback)
88
- if (pkg.main) return pkg.main
89
-
90
- // Default
91
- return '/index.js'
92
- }
103
+ })
93
104
 
94
105
  self.addEventListener('fetch', (event) => {
95
106
  const url = new URL(event.request.url)
96
107
 
97
- // Only intercept /tfs/ requests
98
- if (!url.pathname.startsWith('/tfs/')) return
99
-
100
- const tfsPath = url.pathname.slice(5) // strip '/tfs/'
101
- if (!tfsPath) return
102
-
103
- // Special: /tfs/__status
104
- if (tfsPath === '__status') {
105
- event.respondWith(
106
- caches.open(CACHE_NAME).then(async (cache) => {
107
- const keys = await cache.keys()
108
- return new Response(
109
- JSON.stringify({
110
- version: CACHE_NAME,
111
- cached: keys.map((k) => new URL(k.url).pathname),
112
- }),
113
- { headers: { 'Content-Type': 'application/json' } }
114
- )
115
- })
116
- )
108
+ // /iframe/<sessionId> serve the registered HTML
109
+ if (url.pathname.startsWith('/iframe/')) {
110
+ const sessionId = url.pathname.slice('/iframe/'.length)
111
+ event.respondWith(serveIframeRequest(sessionId))
117
112
  return
118
113
  }
119
114
 
120
- // Special: /tfs/__clear
121
- if (tfsPath === '__clear') {
122
- event.respondWith(
123
- caches
124
- .delete(CACHE_NAME)
125
- .then(() => new Response('cache cleared', { status: 200 }))
126
- )
127
- return
128
- }
115
+ // /tfs/<spec> — proxy to esm.sh
116
+ if (url.pathname.startsWith('/tfs/')) {
117
+ const tfsPath = url.pathname.slice(5)
118
+ if (!tfsPath) return
129
119
 
130
- const parsed = parseTfsPath(tfsPath)
131
- if (!parsed) {
132
- event.respondWith(new Response('invalid tfs path', { status: 400 }))
120
+ if (tfsPath === '__status') {
121
+ event.respondWith(serveStatus())
122
+ return
123
+ }
124
+ if (tfsPath === '__clear') {
125
+ event.respondWith(serveClear())
126
+ return
127
+ }
128
+
129
+ const parsed = parseTfsPath(tfsPath)
130
+ if (!parsed) {
131
+ event.respondWith(new Response('invalid tfs path', { status: 400 }))
132
+ return
133
+ }
134
+ event.respondWith(serveTfsRequest(parsed))
133
135
  return
134
136
  }
135
137
 
136
- event.respondWith(serveTfsRequest(parsed, event.request))
138
+ // Anything else: let it pass through to the network
137
139
  })
138
140
 
139
- async function serveTfsRequest({ name, version, subpath }, request) {
141
+ function serveIframeRequest(sessionId) {
142
+ const html = iframeContents.get(sessionId)
143
+ if (!html) {
144
+ return new Response(
145
+ `iframe content not found for session ${sessionId} — playground may not have registered yet`,
146
+ { status: 404, headers: { 'Content-Type': 'text/plain' } }
147
+ )
148
+ }
149
+ return new Response(html, {
150
+ status: 200,
151
+ headers: {
152
+ 'Content-Type': 'text/html',
153
+ // No-cache: each playground run registers fresh content
154
+ 'Cache-Control': 'no-store',
155
+ },
156
+ })
157
+ }
158
+
159
+ async function serveStatus() {
140
160
  const cache = await caches.open(CACHE_NAME)
161
+ const keys = await cache.keys()
162
+ return new Response(
163
+ JSON.stringify({
164
+ version: CACHE_NAME,
165
+ cdn: { default: JSDELIVR, esmSh: [...ESM_SH_PACKAGES] },
166
+ cached: keys.map((k) => new URL(k.url).pathname),
167
+ iframeSessions: [...iframeContents.keys()],
168
+ }),
169
+ { headers: { 'Content-Type': 'application/json' } }
170
+ )
171
+ }
141
172
 
142
- // Resolve entry point if no subpath
143
- if (!subpath) {
144
- const entry = await resolveEntryPoint(name, version)
145
- if (!entry) {
146
- return new Response(`package not found: ${name}@${version}`, {
147
- status: 404,
148
- })
149
- }
150
- subpath = entry.startsWith('/')
151
- ? entry
152
- : entry.startsWith('./')
153
- ? entry.slice(1)
154
- : `/${entry}`
173
+ async function serveClear() {
174
+ await caches.delete(CACHE_NAME)
175
+ return new Response('cache cleared', { status: 200 })
176
+ }
177
+
178
+ /**
179
+ * Build the CDN URL for a parsed TFS path. JSDelivr `/+esm` by default;
180
+ * esm.sh for packages in ESM_SH_PACKAGES.
181
+ *
182
+ * react → https://esm.sh/react
183
+ * react-dom/client → https://esm.sh/react-dom/client
184
+ * tosijs → https://cdn.jsdelivr.net/npm/tosijs@latest/+esm
185
+ * tosijs@1.6.1 → https://cdn.jsdelivr.net/npm/tosijs@1.6.1/+esm
186
+ * lodash-es/get → https://cdn.jsdelivr.net/npm/lodash-es@latest/get/+esm
187
+ */
188
+ function buildCdnUrl(name, version, subpath) {
189
+ // Explicit hint: `<cdn>/<rest>` where <cdn> is a known CDN name
190
+ if (CDN_HINTS[name] && subpath) {
191
+ // subpath starts with `/` — strip it. The version slot is unused for
192
+ // hinted specifiers since the inner spec carries its own version.
193
+ return CDN_HINTS[name](subpath.slice(1))
194
+ }
195
+ // Allowlist override (peer-dep packages must use esm.sh)
196
+ if (ESM_SH_PACKAGES.has(name)) {
197
+ const versionPart = version ? `@${version}` : ''
198
+ return `${ESM_SH}/${name}${versionPart}${subpath}`
199
+ }
200
+ // Default: JSDelivr `/+esm`
201
+ const versionPart = version ? `@${version}` : '@latest'
202
+ return `${JSDELIVR}/${name}${versionPart}${subpath}/+esm`
203
+ }
204
+
205
+ /**
206
+ * Parse a TFS path into package name, version, and subpath.
207
+ *
208
+ * tosijs@1.3.11 → { name: 'tosijs', version: '1.3.11', subpath: '' }
209
+ * tosijs → { name: 'tosijs', version: '', subpath: '' }
210
+ * tosijs@1.3.11/utils → { name: 'tosijs', version: '1.3.11', subpath: '/utils' }
211
+ * @scope/pkg@1.0.0 → { name: '@scope/pkg', version: '1.0.0', subpath: '' }
212
+ * @scope/pkg@1.0.0/sub → { name: '@scope/pkg', version: '1.0.0', subpath: '/sub' }
213
+ */
214
+ function parseTfsPath(path) {
215
+ if (path.startsWith('@')) {
216
+ const m = path.match(/^(@[^/]+\/[^/@]+)(?:@([^/]+))?(\/.*)?$/)
217
+ if (m) return { name: m[1], version: m[2] || '', subpath: m[3] || '' }
155
218
  }
219
+ const m = path.match(/^([^/@]+)(?:@([^/]+))?(\/.*)?$/)
220
+ if (m) return { name: m[1], version: m[2] || '', subpath: m[3] || '' }
221
+ return null
222
+ }
156
223
 
157
- const cdnUrl = `${CDN_BASE}/${name}@${version}${subpath}`
224
+ async function serveTfsRequest({ name, version, subpath }) {
225
+ const cache = await caches.open(CACHE_NAME)
226
+ const cdnUrl = buildCdnUrl(name, version, subpath)
158
227
  const cacheKey = new Request(cdnUrl)
159
228
 
160
- // Check cache
161
229
  const cached = await cache.match(cacheKey)
162
230
  if (cached) {
163
231
  return new Response(cached.body, {
@@ -170,7 +238,6 @@ async function serveTfsRequest({ name, version, subpath }, request) {
170
238
  })
171
239
  }
172
240
 
173
- // Fetch from CDN
174
241
  try {
175
242
  const response = await fetch(cdnUrl)
176
243
  if (!response.ok) {
@@ -178,29 +245,20 @@ async function serveTfsRequest({ name, version, subpath }, request) {
178
245
  }
179
246
 
180
247
  let body = await response.text()
181
- const origin = new URL(request.url).origin
182
- const pkgBase = `${CDN_BASE}/${name}@${version}`
183
-
184
- // Rewrite imports in fetched module:
185
- // - Bare specifiers /tfs/ URLs (transitive deps, single copy)
186
- // - Relative imports absolute CDN URLs (sibling files within package)
187
- body = body.replace(
188
- /((?:import|export)\s+(?:[\w\s{},*]+\s+from\s+)?)(['"])([^'"]+)\2/g,
189
- (match, prefix, quote, spec) => {
190
- if (spec.startsWith('http://') || spec.startsWith('https://'))
191
- return match
192
- if (spec.startsWith('./') || spec.startsWith('../')) {
193
- const dir = subpath.replace(/\/[^/]*$/, '')
194
- // Add .js extension if missing (CDN requires it)
195
- const specWithExt = /\.\w+$/.test(spec) ? spec : `${spec}.js`
196
- const resolved = new URL(specWithExt, `${pkgBase}${dir}/`).href
197
- return `${prefix}${quote}${resolved}${quote}`
198
- }
199
- if (spec.startsWith('/')) return match
200
- // Bare specifier → route through /tfs/ for dedup
201
- return `${prefix}${quote}${origin}/tfs/${spec}${quote}`
202
- }
203
- )
248
+
249
+ // esm.sh's response uses esm.sh-relative paths (`/react@VERSION/...`).
250
+ // Loaded from the playground origin those would resolve back to us
251
+ // instead of esm.sh. Rewrite to absolute esm.sh URLs so the browser
252
+ // fetches them directly AND dedupes cross-package peer-dep references
253
+ // (react react-dom). JSDelivr `/+esm` responses are self-contained
254
+ // and don't have any `/...` paths, so this is a no-op for them.
255
+ if (cdnUrl.startsWith(ESM_SH)) {
256
+ body = body.replace(
257
+ /((?:import|export)\s+(?:[\w\s{},*]+\s+from\s+)?)(['"])(\/[^'"]+)\2/g,
258
+ (_match, prefix, quote, path) =>
259
+ `${prefix}${quote}${ESM_SH}${path}${quote}`
260
+ )
261
+ }
204
262
 
205
263
  await cache.put(
206
264
  cacheKey,
@@ -25,7 +25,11 @@ import {
25
25
  import { codeMirror, CodeMirror } from '../../editors/codemirror/component'
26
26
  import { tjs, generateDTS, type TJSTranspileOptions } from '../../src/lang'
27
27
  import { generateDocsMarkdown } from './docs-utils'
28
- import { rewriteImports, extractImports } from './imports'
28
+ import {
29
+ rewriteImports,
30
+ extractImports,
31
+ registerIframeContent,
32
+ } from './imports'
29
33
  import {
30
34
  buildIframeDoc,
31
35
  createIframeMessageHandler,
@@ -1259,8 +1263,8 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
1259
1263
  const cssContent = this.parts.cssEditor.value
1260
1264
  const jsCode = this.lastTranspileResult.code
1261
1265
 
1262
- // Rewrite bare import specifiers to /tfs/ URLs
1263
- // The TFS service worker handles CDN resolution and caching
1266
+ // Rewrite bare import specifiers to absolute JSDelivr /+esm URLs
1267
+ // (sandboxed blob iframes can't reliably use the SW)
1264
1268
  const rewrittenCode = rewriteImports(jsCode)
1265
1269
 
1266
1270
  // Extract import statements — they must be at the top of the module
@@ -1304,18 +1308,38 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
1304
1308
  })
1305
1309
  window.addEventListener('message', messageHandler)
1306
1310
 
1307
- // Set iframe content using blob URL instead of srcdoc
1308
- // This allows import maps to work with external URLs
1311
+ // Load the iframe from a same-origin URL the TFS service worker
1312
+ // serves. This makes the iframe SW-controlled, so its module imports
1313
+ // (and future virtual-module reads) go through the SW. blob: URLs
1314
+ // can't do this — Chrome SWs don't intercept fetches from sandboxed
1315
+ // blob iframes.
1309
1316
  const iframe = this.parts.previewFrame
1310
- const blob = new Blob([iframeDoc], { type: 'text/html' })
1311
- const blobUrl = URL.createObjectURL(blob)
1312
1317
 
1313
- // Clean up previous blob URL if any
1318
+ // Clean up previous blob URL if any (legacy fallback path)
1314
1319
  if (iframe.dataset.blobUrl) {
1315
1320
  URL.revokeObjectURL(iframe.dataset.blobUrl)
1321
+ delete iframe.dataset.blobUrl
1322
+ }
1323
+
1324
+ const sessionId = `tjs-${Date.now()}-${Math.random()
1325
+ .toString(36)
1326
+ .slice(2)}`
1327
+ const registered = await registerIframeContent(sessionId, iframeDoc)
1328
+
1329
+ if (registered) {
1330
+ iframe.src = `/iframe/${sessionId}`
1331
+ } else {
1332
+ // SW unavailable — fall back to blob: (no SW interception, but
1333
+ // CDN imports still work via the absolute URLs in the rewritten
1334
+ // /tfs/ paths once the SW is back).
1335
+ console.warn(
1336
+ '[playground] SW unavailable, falling back to blob: iframe'
1337
+ )
1338
+ const blob = new Blob([iframeDoc], { type: 'text/html' })
1339
+ const blobUrl = URL.createObjectURL(blob)
1340
+ iframe.dataset.blobUrl = blobUrl
1341
+ iframe.src = blobUrl
1316
1342
  }
1317
- iframe.dataset.blobUrl = blobUrl
1318
- iframe.src = blobUrl
1319
1343
 
1320
1344
  // Wait a bit for execution, then clean up listener
1321
1345
  setTimeout(() => {