tjs-lang 0.7.6 → 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.
- package/CLAUDE.md +101 -26
- package/bin/docs.js +4 -1
- package/demo/docs.json +46 -12
- package/demo/src/examples.test.ts +1 -0
- package/demo/src/imports.test.ts +16 -4
- package/demo/src/imports.ts +60 -15
- package/demo/src/playground-shared.ts +9 -8
- package/demo/src/tfs-worker.js +205 -147
- package/demo/src/tjs-playground.ts +34 -10
- package/demo/src/ts-playground.ts +24 -8
- package/dist/index.js +140 -119
- package/dist/index.js.map +4 -4
- package/dist/src/lang/bool-coercion.d.ts +50 -0
- package/dist/src/lang/docs.d.ts +31 -6
- package/dist/src/lang/linter.d.ts +8 -0
- package/dist/src/lang/parser-transforms.d.ts +18 -0
- package/dist/src/lang/parser-types.d.ts +2 -0
- package/dist/src/lang/parser.d.ts +9 -0
- package/dist/src/lang/runtime.d.ts +34 -0
- package/dist/src/lang/types.d.ts +9 -1
- package/dist/src/rbac/index.d.ts +1 -1
- package/dist/src/vm/runtime.d.ts +1 -1
- package/dist/tjs-eval.js +44 -39
- package/dist/tjs-eval.js.map +4 -4
- package/dist/tjs-from-ts.js +20 -20
- package/dist/tjs-from-ts.js.map +3 -3
- package/dist/tjs-lang.js +86 -80
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-vm.js +50 -45
- package/dist/tjs-vm.js.map +4 -4
- package/llms.txt +79 -0
- package/package.json +3 -2
- package/src/cli/commands/convert.test.ts +16 -21
- package/src/lang/bool-coercion.test.ts +203 -0
- package/src/lang/bool-coercion.ts +314 -0
- package/src/lang/codegen.test.ts +177 -0
- package/src/lang/docs.test.ts +328 -1
- package/src/lang/docs.ts +424 -24
- package/src/lang/emitters/ast.ts +11 -12
- package/src/lang/emitters/dts.test.ts +41 -0
- package/src/lang/emitters/dts.ts +9 -0
- package/src/lang/emitters/js-tests.ts +16 -4
- package/src/lang/emitters/js.ts +208 -2
- package/src/lang/features.test.ts +22 -0
- package/src/lang/inference.ts +54 -0
- package/src/lang/linter.test.ts +104 -1
- package/src/lang/linter.ts +124 -1
- package/src/lang/parser-params.ts +31 -0
- package/src/lang/parser-transforms.ts +539 -6
- package/src/lang/parser-types.ts +2 -0
- package/src/lang/parser.test.ts +73 -1
- package/src/lang/parser.ts +85 -1
- package/src/lang/runtime.ts +98 -0
- package/src/lang/tests.ts +21 -8
- package/src/lang/types.ts +6 -0
- package/src/rbac/index.ts +2 -2
- package/src/rbac/rules.tjs.d.ts +9 -0
- package/src/vm/atoms/batteries.ts +2 -2
- package/src/vm/runtime.ts +10 -3
- package/dist/src/rbac/rules.d.ts +0 -184
- package/src/rbac/rules.js +0 -338
package/demo/src/imports.ts
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Playground Import Resolver
|
|
2
|
+
* Playground Import Resolver
|
|
3
3
|
*
|
|
4
|
-
* Rewrites bare import specifiers to /tfs
|
|
5
|
-
*
|
|
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
|
|
9
|
-
* →
|
|
10
|
-
* →
|
|
11
|
-
* →
|
|
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
|
-
*
|
|
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
|
|
37
|
-
*
|
|
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 '
|
|
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}
|
|
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
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
282
|
+
failedWithLines.map((t: any) => ({
|
|
280
283
|
line: t.line,
|
|
281
|
-
message: t.
|
|
282
|
-
|
|
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 {
|
package/demo/src/tfs-worker.js
CHANGED
|
@@ -1,163 +1,231 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TFS — TJS File System Service Worker
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Handles caching, version resolution, and ESM serving.
|
|
4
|
+
* Acts as a tiny in-browser server for the playground iframe:
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
* /tfs
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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
|
|
17
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
//
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
//
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
138
|
+
// Anything else: let it pass through to the network
|
|
137
139
|
})
|
|
138
140
|
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// Rewrite
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 {
|
|
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
|
|
1263
|
-
//
|
|
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
|
-
//
|
|
1308
|
-
// This
|
|
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(() => {
|