octane 0.1.5 → 0.1.6

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.
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Bundler-neutral Octane source integration.
3
+ *
4
+ * This module owns every decision shared by Vite, Rspack, and future bundlers:
5
+ * source eligibility, package-manifest rules, canonical compiler IDs, compiler
6
+ * target/HMR options, raw-source dependency discovery, and runtime requests.
7
+ * Bundler adapters are responsible only for translating their own lifecycle and
8
+ * watch APIs to this small surface.
9
+ */
10
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
11
+ import { createRequire } from 'node:module';
12
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
13
+ import { compile } from './compile.js';
14
+ import { slotHooks } from './slot-hooks.js';
15
+
16
+ const OCTANE_DEPENDENCY_FIELDS = [
17
+ 'dependencies',
18
+ 'devDependencies',
19
+ 'optionalDependencies',
20
+ 'peerDependencies',
21
+ ];
22
+
23
+ export const OCTANE_RUNTIME_REQUESTS = Object.freeze({
24
+ client: 'octane',
25
+ server: 'octane/server',
26
+ });
27
+
28
+ /** Strip bundler query/hash suffixes without changing the underlying path. */
29
+ export function cleanModuleId(id) {
30
+ const query = id.indexOf('?');
31
+ const hash = id.indexOf('#');
32
+ let end = id.length;
33
+ if (query !== -1) end = query;
34
+ if (hash !== -1 && hash < end) end = hash;
35
+ return id.slice(0, end);
36
+ }
37
+
38
+ function isPathInside(root, file) {
39
+ const relativeFile = relative(root, file);
40
+ return relativeFile !== '..' && !relativeFile.startsWith('..' + sep) && !isAbsolute(relativeFile);
41
+ }
42
+
43
+ function normalizeModulePath(file) {
44
+ return file.split(/[\\/]/).join('/');
45
+ }
46
+
47
+ /**
48
+ * Return the stable ID embedded in hook keys and dev source metadata. Files
49
+ * inside the project root use a root-relative POSIX path so builds are portable;
50
+ * external files retain their absolute path. Bundler query suffixes never enter
51
+ * compiler output or cache keys.
52
+ */
53
+ export function canonicalModuleId(id, projectRoot) {
54
+ const file = cleanModuleId(id);
55
+ if (!projectRoot || !isAbsolute(file)) return normalizeModulePath(file);
56
+ const root = resolve(projectRoot);
57
+ const relativeFile = relative(root, file);
58
+ if (!isPathInside(root, file)) return normalizeModulePath(file);
59
+ return '/' + normalizeModulePath(relativeFile);
60
+ }
61
+
62
+ export function resolveOctaneRuntimeRequest(request, environment) {
63
+ if (request !== 'octane') return null;
64
+ if (environment !== 'client' && environment !== 'server') {
65
+ throw new Error(
66
+ `Unknown Octane environment ${JSON.stringify(environment)} — expected 'client' or 'server'.`,
67
+ );
68
+ }
69
+ return OCTANE_RUNTIME_REQUESTS[environment];
70
+ }
71
+
72
+ function packageUsesOctane(pkg) {
73
+ return (
74
+ pkg.name === 'octane' ||
75
+ ['dependencies', 'optionalDependencies', 'peerDependencies'].some(
76
+ (field) => typeof pkg[field]?.octane === 'string',
77
+ )
78
+ );
79
+ }
80
+
81
+ function metadata(dependencies = [], missingDependencies = []) {
82
+ return { dependencies, missingDependencies };
83
+ }
84
+
85
+ function addMetadata(target, source) {
86
+ for (const file of source.dependencies) target.dependencies.add(file);
87
+ for (const file of source.missingDependencies) target.missingDependencies.add(file);
88
+ }
89
+
90
+ function finishMetadata(value) {
91
+ return {
92
+ dependencies: [...value.dependencies].sort(),
93
+ missingDependencies: [...value.missingDependencies].sort(),
94
+ };
95
+ }
96
+
97
+ function normalizeHmrDialect(value) {
98
+ // Backwards compatibility: compile(..., { hmr: true }) has always meant the
99
+ // Vite import.meta.hot dialect.
100
+ if (value === true) return 'vite';
101
+ if (value === false || value == null) return false;
102
+ if (value === 'vite' || value === 'webpack') return value;
103
+ throw new Error(
104
+ `Unknown Octane HMR dialect ${JSON.stringify(value)} — expected false, 'vite', or 'webpack'.`,
105
+ );
106
+ }
107
+
108
+ class OctaneBundlerCompiler {
109
+ constructor(options) {
110
+ this.root = resolve(options.root ?? process.cwd());
111
+ try {
112
+ this.realRoot = realpathSync(this.root);
113
+ } catch {
114
+ this.realRoot = this.root;
115
+ }
116
+ this.exclude = [...(options.exclude ?? [])];
117
+ this.defaults = {
118
+ environment: options.environment ?? 'client',
119
+ hmr: normalizeHmrDialect(options.hmr),
120
+ dev: options.dev,
121
+ parallelUse: options.parallelUse,
122
+ };
123
+ // Deliberately instance-scoped: separate projects/build environments must
124
+ // never share nearest-manifest decisions.
125
+ this.manifestRuleCache = new Map();
126
+ this.discoveryCache = null;
127
+ }
128
+
129
+ /** Clear cached manifest/discovery decisions after a watched path changes. */
130
+ invalidate(path) {
131
+ if (path == null) {
132
+ this.manifestRuleCache.clear();
133
+ this.discoveryCache = null;
134
+ return;
135
+ }
136
+ const changed = resolve(cleanModuleId(path));
137
+ for (const [directory, entry] of this.manifestRuleCache) {
138
+ if (entry.dependencies.includes(changed) || entry.missingDependencies.includes(changed)) {
139
+ this.manifestRuleCache.delete(directory);
140
+ }
141
+ }
142
+ if (
143
+ this.discoveryCache?.dependencies.includes(changed) ||
144
+ this.discoveryCache?.missingDependencies.includes(changed)
145
+ ) {
146
+ this.discoveryCache = null;
147
+ }
148
+ }
149
+
150
+ _nearestOctanePackageRule(fileDir) {
151
+ const dir = resolve(fileDir);
152
+ const cached = this.manifestRuleCache.get(dir);
153
+ if (cached !== undefined) return cached;
154
+
155
+ const manifest = join(dir, 'package.json');
156
+ let pkg = null;
157
+ try {
158
+ pkg = JSON.parse(readFileSync(manifest, 'utf8'));
159
+ } catch {
160
+ // An absent/unreadable/invalid manifest does not own the file. Continue
161
+ // upward, while retaining the path as watch/cache metadata.
162
+ }
163
+
164
+ let result;
165
+ if (pkg !== null) {
166
+ const manual = pkg.octane?.hookSlots?.manual;
167
+ result = {
168
+ rule: {
169
+ root: dir,
170
+ dirs: Array.isArray(manual) ? manual : [],
171
+ runtimeDependencies: [
172
+ ...Object.keys(pkg.dependencies ?? {}),
173
+ ...Object.keys(pkg.optionalDependencies ?? {}),
174
+ ],
175
+ usesOctane: packageUsesOctane(pkg),
176
+ },
177
+ ...metadata([manifest]),
178
+ };
179
+ } else {
180
+ const parent = dirname(dir);
181
+ const inherited =
182
+ parent === dir ? { rule: null, ...metadata() } : this._nearestOctanePackageRule(parent);
183
+ result = {
184
+ rule: inherited.rule,
185
+ dependencies: existsSync(manifest)
186
+ ? [manifest, ...inherited.dependencies]
187
+ : inherited.dependencies,
188
+ missingDependencies: existsSync(manifest)
189
+ ? inherited.missingDependencies
190
+ : [manifest, ...inherited.missingDependencies],
191
+ };
192
+ }
193
+
194
+ this.manifestRuleCache.set(dir, result);
195
+ return result;
196
+ }
197
+
198
+ _hasManualHookSlots(file, collected) {
199
+ const lookup = this._nearestOctanePackageRule(dirname(file));
200
+ addMetadata(collected, lookup);
201
+ if (lookup.rule === null) return false;
202
+ const relativeFile = relative(lookup.rule.root, file);
203
+ return lookup.rule.dirs.some((directory) => {
204
+ const relativeDirectory = directory
205
+ .replace(/[\\/]+$/, '')
206
+ .split(/[\\/]/)
207
+ .join(sep);
208
+ return (
209
+ relativeDirectory !== '' &&
210
+ (relativeFile === relativeDirectory || relativeFile.startsWith(relativeDirectory + sep))
211
+ );
212
+ });
213
+ }
214
+
215
+ _isInstalledOctaneSource(file, collected) {
216
+ const absoluteFile = isAbsolute(file) ? resolve(file) : resolve(this.root, file);
217
+ const isInstalledPath = /(?:^|[\\/])node_modules(?:[\\/]|$)/.test(absoluteFile);
218
+ // Project-owned TS/JS/TSX is always eligible. A linked package is commonly
219
+ // handed to bundlers as its real path, without a node_modules segment, so
220
+ // external files must make the same manifest-declared Octane decision as an
221
+ // installed package instead of being mistaken for application source.
222
+ if (
223
+ !isInstalledPath &&
224
+ (isPathInside(this.root, absoluteFile) || isPathInside(this.realRoot, absoluteFile))
225
+ ) {
226
+ return true;
227
+ }
228
+ const lookup = this._nearestOctanePackageRule(dirname(absoluteFile));
229
+ addMetadata(collected, lookup);
230
+ return lookup.rule?.usesOctane === true;
231
+ }
232
+
233
+ /**
234
+ * Discover installed source packages which consume Octane, recursively
235
+ * following runtime dependencies between those packages.
236
+ */
237
+ discoverSourceDependencies() {
238
+ if (this.discoveryCache !== null) return this.discoveryCache;
239
+ const collected = {
240
+ dependencies: new Set(),
241
+ missingDependencies: new Set(),
242
+ };
243
+ const projectManifestPath = join(this.root, 'package.json');
244
+ let projectManifest;
245
+ try {
246
+ projectManifest = JSON.parse(readFileSync(projectManifestPath, 'utf8'));
247
+ collected.dependencies.add(projectManifestPath);
248
+ } catch {
249
+ if (existsSync(projectManifestPath)) collected.dependencies.add(projectManifestPath);
250
+ else collected.missingDependencies.add(projectManifestPath);
251
+ this.discoveryCache = { packages: [], ...finishMetadata(collected) };
252
+ return this.discoveryCache;
253
+ }
254
+
255
+ const dependencyNames = new Set();
256
+ for (const field of OCTANE_DEPENDENCY_FIELDS) {
257
+ for (const name of Object.keys(projectManifest[field] ?? {})) dependencyNames.add(name);
258
+ }
259
+ const sourceDependencies = new Set();
260
+ const visitedPackageRoots = new Set();
261
+ const visit = (name, issuerRoot) => {
262
+ const packageRequire = createRequire(join(issuerRoot, 'package.json'));
263
+ try {
264
+ const entry = packageRequire.resolve(name);
265
+ const lookup = this._nearestOctanePackageRule(dirname(entry));
266
+ addMetadata(collected, lookup);
267
+ if (!lookup.rule?.usesOctane) return;
268
+ sourceDependencies.add(name);
269
+ let packageRoot = lookup.rule.root;
270
+ try {
271
+ packageRoot = realpathSync(packageRoot);
272
+ } catch {
273
+ // Keep the resolved/symlink path as the cycle key.
274
+ }
275
+ if (visitedPackageRoots.has(packageRoot)) return;
276
+ visitedPackageRoots.add(packageRoot);
277
+ for (const dependency of lookup.rule.runtimeDependencies) {
278
+ visit(dependency, lookup.rule.root);
279
+ }
280
+ } catch {
281
+ // Match Node's upward node_modules search: package managers commonly
282
+ // satisfy a nested raw-source dependency by hoisting it to the project
283
+ // root. Any candidate's creation can therefore make this request
284
+ // resolvable and must invalidate a cached miss.
285
+ let candidateRoot = resolve(issuerRoot);
286
+ for (;;) {
287
+ collected.missingDependencies.add(
288
+ join(candidateRoot, 'node_modules', name, 'package.json'),
289
+ );
290
+ const parent = dirname(candidateRoot);
291
+ if (parent === candidateRoot) break;
292
+ candidateRoot = parent;
293
+ }
294
+ }
295
+ };
296
+ for (const name of dependencyNames) visit(name, this.root);
297
+
298
+ this.discoveryCache = {
299
+ packages: [...sourceDependencies].sort(),
300
+ ...finishMetadata(collected),
301
+ };
302
+ return this.discoveryCache;
303
+ }
304
+
305
+ resolveRuntimeRequest(request, environment = this.defaults.environment) {
306
+ return resolveOctaneRuntimeRequest(request, environment);
307
+ }
308
+
309
+ _passThrough(code, collected) {
310
+ if (collected.dependencies.size === 0 && collected.missingDependencies.size === 0) {
311
+ return null;
312
+ }
313
+ return {
314
+ code,
315
+ map: null,
316
+ kind: 'none',
317
+ ...finishMetadata(collected),
318
+ };
319
+ }
320
+
321
+ transform(code, id, options = {}) {
322
+ const file = cleanModuleId(id);
323
+ const collected = {
324
+ dependencies: new Set(),
325
+ missingDependencies: new Set(),
326
+ };
327
+ const environment = options.environment ?? this.defaults.environment;
328
+ if (environment !== 'client' && environment !== 'server') {
329
+ throw new Error(
330
+ `Unknown Octane environment ${JSON.stringify(environment)} — expected 'client' or 'server'.`,
331
+ );
332
+ }
333
+ const requestedHmr = normalizeHmrDialect(options.hmr ?? this.defaults.hmr);
334
+ const hmr = environment === 'server' ? false : requestedHmr;
335
+ const dev = environment === 'server' ? false : (options.dev ?? this.defaults.dev ?? !!hmr);
336
+ const parallelUse = options.parallelUse ?? this.defaults.parallelUse;
337
+ const filename = canonicalModuleId(file, this.root);
338
+
339
+ const fullCompile =
340
+ file.endsWith('.tsrx') ||
341
+ (file.endsWith('.tsx') && this._isInstalledOctaneSource(file, collected));
342
+ if (fullCompile) {
343
+ const out = compile(code, filename, {
344
+ hmr,
345
+ mode: environment,
346
+ dev,
347
+ parallelUse: parallelUse !== false,
348
+ });
349
+ return {
350
+ code: out.code,
351
+ map: out.map,
352
+ kind: 'compile',
353
+ ...finishMetadata(collected),
354
+ };
355
+ }
356
+ if (file.endsWith('.tsx')) return this._passThrough(code, collected);
357
+
358
+ if ((file.endsWith('.ts') || file.endsWith('.js')) && !file.endsWith('.d.ts')) {
359
+ if (/\/\/\s*octane-no-slot\b/.test(code)) return null;
360
+ if (this.exclude.some((path) => file.includes(path))) return null;
361
+ if (!/from\s*['"]octane['"]/.test(code)) return null;
362
+ if (!this._isInstalledOctaneSource(file, collected)) {
363
+ return this._passThrough(code, collected);
364
+ }
365
+ if (this._hasManualHookSlots(file, collected)) {
366
+ return this._passThrough(code, collected);
367
+ }
368
+ const out = slotHooks(code, filename, { hmr: !!hmr });
369
+ if (out === null) return this._passThrough(code, collected);
370
+ return {
371
+ code: out.code,
372
+ map: out.map,
373
+ kind: 'slots',
374
+ ...finishMetadata(collected),
375
+ };
376
+ }
377
+
378
+ return null;
379
+ }
380
+ }
381
+
382
+ export function createOctaneCompiler(options = {}) {
383
+ return new OctaneBundlerCompiler(options);
384
+ }
385
+
386
+ /** Backwards-compatible convenience for callers that only need package names. */
387
+ export function discoverOctaneSourceDependencies(projectRoot) {
388
+ return createOctaneCompiler({ root: projectRoot }).discoverSourceDependencies().packages;
389
+ }