@lerret/cli 0.1.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/LICENSE +21 -0
- package/dist-studio/.bundle-stamp +34 -0
- package/dist-studio/assets/asset-runtime-MFjDKvQD.js +129 -0
- package/dist-studio/assets/cli-project-source-9dNA_gVa.js +1 -0
- package/dist-studio/assets/dev-harness-BH6a8T7l.js +18 -0
- package/dist-studio/assets/hosted-project-source-dVGq_8c6.js +135 -0
- package/dist-studio/assets/index-BNmJ8c2t.css +1 -0
- package/dist-studio/assets/index-EslqdOhg.js +10 -0
- package/dist-studio/assets/leaf-marker-command.png +0 -0
- package/dist-studio/assets/leaf-marker-comment-box.png +0 -0
- package/dist-studio/assets/leaf-marker-homescreen.png +0 -0
- package/dist-studio/assets/leafmarker-icon-dark-128.png +0 -0
- package/dist-studio/assets/leafmarker-logo-transparent.png +0 -0
- package/dist-studio/assets/leafmarker-logo.png +0 -0
- package/dist-studio/assets/lerret-logo.png +0 -0
- package/dist-studio/assets/lerret-wordmark.svg +3 -0
- package/dist-studio/assets/logo-angular.svg +1 -0
- package/dist-studio/assets/logo-claude.svg +7 -0
- package/dist-studio/assets/logo-codex.svg +1 -0
- package/dist-studio/assets/logo-cursor.svg +1 -0
- package/dist-studio/assets/logo-javascript.svg +1 -0
- package/dist-studio/assets/logo-react.svg +1 -0
- package/dist-studio/assets/logo-svelte.svg +1 -0
- package/dist-studio/assets/logo-vue.svg +1 -0
- package/dist-studio/assets/open-folder-D5OR7eLb.js +8 -0
- package/dist-studio/assets/project-studio-BjNaIuRb.js +795 -0
- package/dist-studio/assets/project-studio-CKuMOMsC.css +1 -0
- package/dist-studio/assets/superwhisper-logo.png +0 -0
- package/dist-studio/index.html +47 -0
- package/dist-studio/module-sw.js +275 -0
- package/package.json +51 -0
- package/src/dev.js +373 -0
- package/src/export.js +1386 -0
- package/src/fs/node-backend.js +631 -0
- package/src/lerret.js +143 -0
- package/src/resolve-project.js +178 -0
- package/src/vite-plugin-lerret-project.js +986 -0
- package/src/watcher.js +214 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.lm-menu-popover{padding:var(--lm-space-1);background:var(--lm-popover-bg);-webkit-backdrop-filter:blur(16px)saturate(120%);border:1px solid var(--lm-popover-border);border-radius:var(--lm-radius-lg);min-width:200px;max-width:320px;max-height:320px;box-shadow:var(--lm-shadow-popup);z-index:80;font-family:var(--lm-font-sans);color:var(--lm-text-primary);animation:lm-menu-open var(--lm-duration-fast) var(--lm-ease) both;outline:none;margin:0;list-style:none;position:fixed;overflow-y:auto}@keyframes lm-menu-open{0%{opacity:0;transform:scale(.96)translateY(4px)}to{opacity:1;transform:scale(1)translateY(0)}}@media (prefers-reduced-motion:reduce){.lm-menu-popover{animation:none}}.lm-menu-item{align-items:center;gap:var(--lm-space-2);width:100%;padding:var(--lm-space-2) var(--lm-space-3);border-radius:var(--lm-radius-md);color:var(--lm-text-primary);font-family:var(--lm-font-sans);font-size:var(--lm-size-body);font-weight:var(--lm-weight-medium);line-height:var(--lm-lh-body);text-align:left;cursor:pointer;transition:background var(--lm-duration-fast), box-shadow var(--lm-duration-fast);background:0 0;border:none;outline:none;list-style:none;display:flex}@media (prefers-reduced-motion:reduce){.lm-menu-item{transition:none}}.lm-menu-item[data-active=true]{background:var(--lm-accent-light);box-shadow:inset 0 0 0 1.5px var(--lm-accent)}.lm-menu-item:hover:not([aria-disabled=true]){background:var(--lm-bg-tertiary)}.lm-menu-item[aria-disabled=true]{color:var(--lm-text-muted);cursor:not-allowed}.lm-menu-item-icon{opacity:.7;font-size:var(--lm-size-body-sm);flex:none;display:inline-flex}.lm-menu-item-label{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.lm-menu-item-reason{font-size:var(--lm-size-body-sm);color:var(--lm-text-tertiary);white-space:nowrap;text-overflow:ellipsis;flex:none;max-width:160px;overflow:hidden}.lm-menu-separator{height:1px;margin:var(--lm-space-1) var(--lm-space-3);background:var(--lm-border-light);list-style:none}.lm-field{gap:var(--lm-space-1,4px);font-family:var(--lm-font-sans);flex-direction:column;display:flex}.lm-field__label-row{align-items:center;gap:var(--lm-space-1,4px);display:flex}.lm-field__label{font-size:var(--lm-size-hint,10px);font-weight:var(--lm-weight-semibold,600);color:var(--lm-text-tertiary,#6e6960);text-transform:uppercase;letter-spacing:var(--lm-tracking-caps,.5px);line-height:1}.lm-field__required{color:var(--lm-error,#a8412b);font-size:var(--lm-size-hint,10px);-webkit-user-select:none;user-select:none;line-height:1}.lm-field__description{font-size:var(--lm-size-body-sm,12px);color:var(--lm-text-muted,#b8b3a8);line-height:var(--lm-lh-body,1.45);margin:0}.lm-field__invalid-msg{align-items:center;gap:var(--lm-space-1,4px);font-size:var(--lm-size-body-sm,12px);color:var(--lm-error,#a8412b);line-height:var(--lm-lh-body,1.45);display:flex}.lm-field__invalid-icon{flex-shrink:0;width:12px;height:12px}.lm-input{box-sizing:border-box;width:100%;padding:var(--lm-space-1,4px) var(--lm-space-2,8px);font-family:var(--lm-font-sans);font-size:var(--lm-size-body,13px);font-weight:var(--lm-weight-regular,400);color:var(--lm-text-primary,#1a1714);background:var(--lm-bg-primary,#faf8f2);border:1px solid var(--lm-border,#ddd7ca);border-radius:var(--lm-radius-sm,6px);transition:border-color var(--lm-duration-fast,.12s) var(--lm-ease);appearance:none;outline:none}.lm-input::placeholder{color:var(--lm-text-muted,#b8b3a8);opacity:1}.lm-input:hover:not(:disabled){border-color:var(--lm-accent-border,#b85b3333)}.lm-input:focus{border-color:var(--lm-accent,#b85b33);box-shadow:var(--lm-focus-ring)}.lm-input:disabled{opacity:.45;cursor:not-allowed;background:var(--lm-bg-tertiary,#e8e2d4)}.lm-input--invalid{border-color:var(--lm-error-border,#a8412b33);background:var(--lm-error-light,#a8412b1a)}.lm-input--invalid:focus{border-color:var(--lm-error,#a8412b);box-shadow:0 0 0 2px var(--lm-error-border,#a8412b33)}.lm-select{cursor:pointer}.lm-toggle{align-items:center;gap:var(--lm-space-2,8px);cursor:pointer;-webkit-user-select:none;user-select:none;display:flex}.lm-toggle__track{border-radius:var(--lm-radius-pill,999px);background:var(--lm-bg-tertiary,#e8e2d4);border:1px solid var(--lm-border,#ddd7ca);width:32px;height:18px;transition:background var(--lm-duration-fast,.12s) var(--lm-ease), border-color var(--lm-duration-fast,.12s) var(--lm-ease);outline:none;flex-shrink:0;align-items:center;display:inline-flex;position:relative}.lm-toggle__track:focus-visible{box-shadow:var(--lm-focus-ring);border-color:var(--lm-accent,#b85b33)}.lm-toggle__track--on{background:var(--lm-accent,#b85b33);border-color:var(--lm-accent,#b85b33)}.lm-toggle__track--invalid{border-color:var(--lm-error,#a8412b)}.lm-toggle__thumb{width:12px;height:12px;box-shadow:var(--lm-shadow-xs);transition:transform var(--lm-duration-fast,.12s) var(--lm-ease);background:#fff;border-radius:50%;position:absolute;left:2px}.lm-toggle__track--on .lm-toggle__thumb{transform:translate(14px)}.lm-toggle__track--disabled{opacity:.45;cursor:not-allowed}.lm-toggle__label{font-size:var(--lm-size-body,13px);color:var(--lm-text-secondary,#3a3530)}.lm-array{gap:var(--lm-space-2,8px);flex-direction:column;display:flex}.lm-array__items{gap:var(--lm-space-1,4px);flex-direction:column;display:flex}.lm-array__item{align-items:center;gap:var(--lm-space-1,4px);padding:var(--lm-space-1,4px) var(--lm-space-2,8px);background:var(--lm-bg-secondary,#f2eee6);border:1px solid var(--lm-border,#ddd7ca);border-radius:var(--lm-radius-sm,6px);display:flex}.lm-array__item-field{flex:1;min-width:0}.lm-array__item-actions{gap:var(--lm-space-1,4px);flex-shrink:0;display:flex}.lm-array__add{align-items:center;gap:var(--lm-space-1,4px);padding:var(--lm-space-1,4px) var(--lm-space-2,8px);font-family:var(--lm-font-sans);font-size:var(--lm-size-body-sm,12px);font-weight:var(--lm-weight-medium,500);color:var(--lm-accent,#b85b33);border:1px dashed var(--lm-accent-border,#b85b3333);border-radius:var(--lm-radius-sm,6px);cursor:pointer;width:100%;transition:background var(--lm-duration-fast,.12s) var(--lm-ease), border-color var(--lm-duration-fast,.12s) var(--lm-ease);background:0 0;outline:none;justify-content:center;display:flex}.lm-array__add:hover:not(:disabled){background:var(--lm-accent-light,#b85b331a);border-color:var(--lm-accent,#b85b33)}.lm-array__add:focus-visible{box-shadow:var(--lm-focus-ring);border-color:var(--lm-accent,#b85b33)}.lm-array__add:disabled{opacity:.45;cursor:not-allowed}.lm-object{gap:var(--lm-space-2,8px);padding:var(--lm-space-2,8px);background:var(--lm-bg-secondary,#f2eee6);border:1px solid var(--lm-border,#ddd7ca);border-radius:var(--lm-radius-sm,6px);flex-direction:column;display:flex}.lm-icon-btn{border-radius:var(--lm-radius-xs,4px);cursor:pointer;width:22px;height:22px;color:var(--lm-text-tertiary,#6e6960);transition:background var(--lm-duration-fast,.12s) var(--lm-ease), color var(--lm-duration-fast,.12s) var(--lm-ease);background:0 0;border:1px solid #0000;outline:none;justify-content:center;align-items:center;padding:0;font-size:14px;line-height:1;display:inline-flex}.lm-icon-btn:hover:not(:disabled){background:var(--lm-bg-tertiary,#e8e2d4);color:var(--lm-text-primary,#1a1714)}.lm-icon-btn:focus-visible{box-shadow:var(--lm-focus-ring);border-color:var(--lm-accent,#b85b33)}.lm-icon-btn:disabled{opacity:.35;cursor:not-allowed}.lm-icon-btn--danger:hover:not(:disabled){background:var(--lm-error-light,#a8412b1a);color:var(--lm-error,#a8412b);border-color:var(--lm-error-border,#a8412b33)}
|
|
Binary file
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Lerret — Studio</title>
|
|
7
|
+
<!--
|
|
8
|
+
Hosted-mode import map placeholder (FR13).
|
|
9
|
+
───────────────────────────────────────────────────
|
|
10
|
+
In hosted mode user assets are transformed in-browser by Sucrase and
|
|
11
|
+
served as ES modules by the studio's service worker
|
|
12
|
+
(`src/runtime/module-sw.js`). Sucrase's automatic JSX runtime emits
|
|
13
|
+
`import { jsx } from "react/jsx-runtime"`, and user assets are free to
|
|
14
|
+
write `import { useState } from "react"`. Those are BARE specifiers,
|
|
15
|
+
not URLs — the browser needs an import map to resolve them.
|
|
16
|
+
|
|
17
|
+
The bundled studio's React module URLs are only known after Vite has
|
|
18
|
+
built the studio (Rolldown produces hashed chunk paths). So the import
|
|
19
|
+
map is INJECTED AT RUNTIME by `src/runtime/sucrase-runtime.js`'s
|
|
20
|
+
`injectReactImportMap()` helper, called by the hosted entry layer
|
|
21
|
+
once it knows the right URLs. The placeholder here is left
|
|
22
|
+
empty so that:
|
|
23
|
+
- in CLI mode (`lerret dev`) it doesn't shadow Vite's own
|
|
24
|
+
dependency-pre-bundle resolution (which handles bare specifiers
|
|
25
|
+
via the bundler, not via the import map);
|
|
26
|
+
- the hosted entry layer's injected import map takes effect first,
|
|
27
|
+
before any asset module is dynamically imported.
|
|
28
|
+
|
|
29
|
+
Chrome 89+ propagates a page's import map into SW-served modules, so
|
|
30
|
+
the same map covers Sucrase-output asset modules.
|
|
31
|
+
-->
|
|
32
|
+
|
|
33
|
+
<style>
|
|
34
|
+
body {
|
|
35
|
+
background: #f1f5f9;
|
|
36
|
+
}
|
|
37
|
+
</style>
|
|
38
|
+
<script type="importmap" id="lerret-import-map">
|
|
39
|
+
{ "imports": {} }
|
|
40
|
+
</script>
|
|
41
|
+
<script type="module" crossorigin src="./assets/index-EslqdOhg.js"></script>
|
|
42
|
+
<link rel="stylesheet" crossorigin href="./assets/index-BNmJ8c2t.css">
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
<div id="root"></div>
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// module-sw.js — the production service worker for hosted-mode asset modules.
|
|
2
|
+
//
|
|
3
|
+
// This SW participates in the hosted runtime as the module-graph layer:
|
|
4
|
+
// the main thread reads asset files via the FSA backend, transforms them with
|
|
5
|
+
// Sucrase (`sucrase-transform.js`), then pre-registers the transformed source
|
|
6
|
+
// with this SW via `postMessage`. When the main thread issues a dynamic
|
|
7
|
+
// `import()` for the module's virtual URL, this SW's `fetch` handler
|
|
8
|
+
// intercepts it and serves the cached source as `text/javascript`.
|
|
9
|
+
//
|
|
10
|
+
// ── Why a service worker ─────────────────────────────────────────────────
|
|
11
|
+
// A blob URL module cannot reliably resolve relative `import` specifiers
|
|
12
|
+
// against other blob URLs — the URL is opaque. A service worker, scoped over
|
|
13
|
+
// the studio's origin, intercepts ANY `fetch` (including dynamic `import`)
|
|
14
|
+
// inside that scope, so we can serve a coherent module graph where each
|
|
15
|
+
// asset's `./logo.png` or `../shared/Card.jsx` resolves to another SW-served
|
|
16
|
+
// URL. This was validated by the spike.
|
|
17
|
+
//
|
|
18
|
+
// ── Pre-register protocol (validated by the spike, refined for production) ──
|
|
19
|
+
// The main thread sends one of these messages:
|
|
20
|
+
//
|
|
21
|
+
// { type: 'PING', id }
|
|
22
|
+
// A handshake the main thread uses to confirm the SW is alive and
|
|
23
|
+
// responding. The SW replies with `{ type: 'PONG', id }` via the
|
|
24
|
+
// `event.source` MessagePort. Optional but useful for entry-screen
|
|
25
|
+
// diagnostics.
|
|
26
|
+
//
|
|
27
|
+
// { type: 'REGISTER_MODULE', url, code, contentType? }
|
|
28
|
+
// Pre-register a transformed asset module's source at `url`. The next
|
|
29
|
+
// dynamic `import(url)` will be served the cached `code`. `contentType`
|
|
30
|
+
// defaults to `'text/javascript'`; the runtime overrides it for non-JS
|
|
31
|
+
// resources (e.g. CSS sources served as `'text/css'`).
|
|
32
|
+
//
|
|
33
|
+
// { type: 'INVALIDATE', url }
|
|
34
|
+
// Drop a single cached URL — used when the runtime knows a URL was
|
|
35
|
+
// replaced by a new cache-busted one.
|
|
36
|
+
//
|
|
37
|
+
// { type: 'INVALIDATE_PREFIX', prefix }
|
|
38
|
+
// Drop every cached URL whose key starts with `prefix`. Used when the
|
|
39
|
+
// project is unmounted / re-mounted to clear stale entries en masse.
|
|
40
|
+
//
|
|
41
|
+
// { type: 'CLAIM' }
|
|
42
|
+
// Force this SW to claim all clients in scope immediately. The main
|
|
43
|
+
// thread sends this once after `register()` resolves, so the very first
|
|
44
|
+
// transformed module the page imports is intercepted (otherwise the
|
|
45
|
+
// uncontrolled-page race lets the first fetch bypass the SW).
|
|
46
|
+
//
|
|
47
|
+
// ── Module URL scheme ────────────────────────────────────────────────────
|
|
48
|
+
// Modules are served under `/__lerret/asset/<path>?h=<hash>`. The leading
|
|
49
|
+
// `/__lerret/` segment makes interception unambiguous and keeps SW concerns
|
|
50
|
+
// from colliding with the studio's own routes. The `?h=<hash>` cache-buster
|
|
51
|
+
// is added by the runtime (content-hash from `sucrase-transform.js`); a new
|
|
52
|
+
// transform produces a new URL so the browser re-fetches a fresh module
|
|
53
|
+
// instance (full remount on reload — see FINDINGS §4.3).
|
|
54
|
+
//
|
|
55
|
+
// ── Eviction ─────────────────────────────────────────────────────────────
|
|
56
|
+
// The spike noted that cached URLs accumulate forever — fine for a spike, not
|
|
57
|
+
// for a long-running session. This SW caps the in-memory map at MAX_MODULES.
|
|
58
|
+
// When full, the oldest registered entry is evicted (FIFO via Map iteration
|
|
59
|
+
// order — Map preserves insertion order in JS). A session with hundreds of
|
|
60
|
+
// edits stays at a bounded memory footprint.
|
|
61
|
+
//
|
|
62
|
+
// ── Globals ──────────────────────────────────────────────────────────────
|
|
63
|
+
// Service workers run in a special global scope where `self`, `clients`,
|
|
64
|
+
// `skipWaiting` are defined by the SW spec. ESLint's `no-undef` doesn't know
|
|
65
|
+
// about them. The block below declares them for the linter.
|
|
66
|
+
|
|
67
|
+
/* global clients */
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Constants
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* URL prefix the SW intercepts. Any fetch for a URL containing this prefix
|
|
75
|
+
* is treated as a request for a registered asset module. The leading
|
|
76
|
+
* `/__lerret/` segment is unlikely to collide with anything in the studio's
|
|
77
|
+
* own routes (the studio is a single-page app under `/`).
|
|
78
|
+
*/
|
|
79
|
+
const ASSET_URL_PREFIX = '/__lerret/asset/';
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Maximum number of registered modules to keep in memory before FIFO
|
|
83
|
+
* eviction kicks in. A realistic project has ≤200 assets × ≤a-handful of
|
|
84
|
+
* cache-busted versions; 2000 is generous and bounds the memory footprint.
|
|
85
|
+
*/
|
|
86
|
+
const MAX_MODULES = 2000;
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// In-memory module store
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @typedef {{ code: string, contentType: string, registeredAt: number }} ModuleEntry
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/** @type {Map<string, ModuleEntry>} */
|
|
97
|
+
const moduleStore = new Map();
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Insert `entry` for `url`, evicting the oldest entry if the store would
|
|
101
|
+
* exceed {@link MAX_MODULES}.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} url
|
|
104
|
+
* @param {ModuleEntry} entry
|
|
105
|
+
*/
|
|
106
|
+
function storeModule(url, entry) {
|
|
107
|
+
// If the URL is already present, delete-then-set so the entry moves to the
|
|
108
|
+
// end (most-recent) of Map iteration order.
|
|
109
|
+
if (moduleStore.has(url)) {
|
|
110
|
+
moduleStore.delete(url);
|
|
111
|
+
} else if (moduleStore.size >= MAX_MODULES) {
|
|
112
|
+
// FIFO eviction: the first key in iteration order is the oldest.
|
|
113
|
+
const oldestKey = moduleStore.keys().next().value;
|
|
114
|
+
if (oldestKey !== undefined) {
|
|
115
|
+
moduleStore.delete(oldestKey);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
moduleStore.set(url, entry);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Lifecycle handlers
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
self.addEventListener('install', (event) => {
|
|
126
|
+
// Skip waiting so the new SW activates immediately — the studio's hosted
|
|
127
|
+
// entry layer is the only caller, and it expects this SW to
|
|
128
|
+
// be live by the time it tries to register the first module.
|
|
129
|
+
event.waitUntil(self.skipWaiting());
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
self.addEventListener('activate', (event) => {
|
|
133
|
+
// Claim all clients in this scope so the very first fetch the page makes
|
|
134
|
+
// after activation is intercepted — without `claim()`, the uncontrolled
|
|
135
|
+
// page would bypass the SW on its first dynamic import.
|
|
136
|
+
event.waitUntil(clients.claim());
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Message handler — pre-register protocol
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
self.addEventListener('message', (event) => {
|
|
144
|
+
const data = event.data;
|
|
145
|
+
if (!data || typeof data !== 'object') return;
|
|
146
|
+
|
|
147
|
+
switch (data.type) {
|
|
148
|
+
case 'PING': {
|
|
149
|
+
// Reply to the sender with a PONG carrying the same id. Used by the
|
|
150
|
+
// entry layer to confirm the SW is alive before mounting
|
|
151
|
+
// the hosted runtime.
|
|
152
|
+
if (event.source && typeof event.source.postMessage === 'function') {
|
|
153
|
+
event.source.postMessage({ type: 'PONG', id: data.id });
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case 'REGISTER_MODULE': {
|
|
158
|
+
if (typeof data.url !== 'string' || typeof data.code !== 'string') return;
|
|
159
|
+
const contentType =
|
|
160
|
+
typeof data.contentType === 'string' && data.contentType.length > 0
|
|
161
|
+
? data.contentType
|
|
162
|
+
: 'text/javascript';
|
|
163
|
+
storeModule(data.url, {
|
|
164
|
+
code: data.code,
|
|
165
|
+
contentType,
|
|
166
|
+
registeredAt: Date.now(),
|
|
167
|
+
});
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case 'INVALIDATE': {
|
|
171
|
+
if (typeof data.url !== 'string') return;
|
|
172
|
+
moduleStore.delete(data.url);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case 'INVALIDATE_PREFIX': {
|
|
176
|
+
if (typeof data.prefix !== 'string' || data.prefix.length === 0) return;
|
|
177
|
+
// Iterate the snapshot of keys (don't mutate during iteration).
|
|
178
|
+
const toDelete = [];
|
|
179
|
+
for (const key of moduleStore.keys()) {
|
|
180
|
+
if (key.startsWith(data.prefix)) toDelete.push(key);
|
|
181
|
+
}
|
|
182
|
+
for (const key of toDelete) moduleStore.delete(key);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case 'CLAIM': {
|
|
186
|
+
// Idempotent — the page may call this multiple times after a reload.
|
|
187
|
+
// `clients.claim()` returns a Promise; we don't `event.waitUntil` here
|
|
188
|
+
// because `message` events don't support it. The Promise is fire-and-
|
|
189
|
+
// forget — the page will retry register-and-import if a race loses.
|
|
190
|
+
try {
|
|
191
|
+
clients.claim();
|
|
192
|
+
} catch {
|
|
193
|
+
// Older browsers may not allow `claim()` outside the activate handler;
|
|
194
|
+
// the studio gracefully degrades to whatever interception the page
|
|
195
|
+
// already had.
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
default:
|
|
200
|
+
// Unknown type — ignore. Future protocol evolution stays additive.
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Fetch handler — intercept dynamic import() of asset URLs
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
self.addEventListener('fetch', (event) => {
|
|
210
|
+
const url = event.request.url;
|
|
211
|
+
// Cheap substring check first to avoid URL parsing for every studio fetch.
|
|
212
|
+
if (url.indexOf(ASSET_URL_PREFIX) === -1) return;
|
|
213
|
+
|
|
214
|
+
// The pathname is the lookup key (origin / scheme are runtime-specific).
|
|
215
|
+
let pathAndQuery;
|
|
216
|
+
try {
|
|
217
|
+
const parsed = new URL(url);
|
|
218
|
+
pathAndQuery = parsed.pathname + parsed.search;
|
|
219
|
+
} catch {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (pathAndQuery.indexOf(ASSET_URL_PREFIX) !== 0) return;
|
|
223
|
+
|
|
224
|
+
event.respondWith(serveModule(pathAndQuery));
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Serve a registered module by URL. A miss returns a JavaScript stub that
|
|
229
|
+
* throws on evaluation — the dynamic `import()` then rejects with the same
|
|
230
|
+
* stub message, which the runtime catches and surfaces as a per-asset error
|
|
231
|
+
* (NFR8). The stub is JavaScript so the browser parses it as a module rather
|
|
232
|
+
* than reporting a low-level network failure.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} key The pathname-plus-search the runtime registered.
|
|
235
|
+
* @returns {Promise<Response>}
|
|
236
|
+
*/
|
|
237
|
+
async function serveModule(key) {
|
|
238
|
+
const entry = moduleStore.get(key);
|
|
239
|
+
if (entry) {
|
|
240
|
+
return new Response(entry.code, {
|
|
241
|
+
status: 200,
|
|
242
|
+
headers: {
|
|
243
|
+
'Content-Type': entry.contentType,
|
|
244
|
+
// The SW is the source of truth for cache-busting; never let the
|
|
245
|
+
// HTTP cache hold a stale copy.
|
|
246
|
+
'Cache-Control': 'no-store',
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
// Miss — emit a JS stub so the import rejects cleanly with a readable
|
|
251
|
+
// message rather than a generic network error.
|
|
252
|
+
const safeKey = JSON.stringify(key);
|
|
253
|
+
const body =
|
|
254
|
+
`// Lerret hosted runtime: no module registered at ${safeKey}.\n` +
|
|
255
|
+
`throw new Error(${JSON.stringify(`Hosted runtime: no module registered at ${key}`)});`;
|
|
256
|
+
return new Response(body, {
|
|
257
|
+
status: 404,
|
|
258
|
+
headers: {
|
|
259
|
+
'Content-Type': 'text/javascript',
|
|
260
|
+
'Cache-Control': 'no-store',
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Documented exports — for the build to detect the file is a valid module
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
//
|
|
269
|
+
// A service worker file is loaded by `navigator.serviceWorker.register(url)`,
|
|
270
|
+
// not by `import`, so this file is normally side-effect-only. Vite's
|
|
271
|
+
// production build, however, doesn't know to copy a side-effect-only
|
|
272
|
+
// `.js` file into the dist/ output unless it is referenced. The runtime's
|
|
273
|
+
// `sucrase-runtime.js` imports the URL of this file with Vite's `?worker&url`
|
|
274
|
+
// idiom (`import swUrl from './module-sw.js?worker&url'`), so the bundler
|
|
275
|
+
// emits it to a stable hashed URL. No re-export is needed here.
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lerret/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The `lerret` design canvas CLI — a folder of plain React component files renders as a visual canvas. Includes the Vite dev server, headless export, and the bundled studio.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20.19.0"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"lerret": "./src/lerret.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"dist-studio",
|
|
16
|
+
"!src/**/*.test.js",
|
|
17
|
+
"!src/**/*.test.jsx"
|
|
18
|
+
],
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/belikely-united/lerret.git",
|
|
22
|
+
"directory": "packages/cli"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://lerret.belikely.com",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/belikely-united/lerret/issues"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"lerret",
|
|
30
|
+
"design-canvas",
|
|
31
|
+
"design-tool",
|
|
32
|
+
"react",
|
|
33
|
+
"vite",
|
|
34
|
+
"cli",
|
|
35
|
+
"local-first"
|
|
36
|
+
],
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
39
|
+
"chokidar": "^5.0.0",
|
|
40
|
+
"playwright-core": "^1.60.0",
|
|
41
|
+
"vite": "^8.0.0",
|
|
42
|
+
"@lerret/core": "^0.1.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"vitest": "^4.1.6"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"build": "pnpm --filter @lerret/studio build:cli && node scripts/bundle-studio.js"
|
|
50
|
+
}
|
|
51
|
+
}
|