laravel-workers 0.0.0-stage → 0.4.1

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 (136) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -2
  3. package/bin/laravel-workers.mjs +141 -0
  4. package/build.mjs +2599 -0
  5. package/compiler/bin/controller.mjs +92 -0
  6. package/compiler/bin/transpile.mjs +65 -0
  7. package/compiler/runtime/rt-from-context.mjs +2399 -0
  8. package/compiler/src/classes.mjs +1232 -0
  9. package/compiler/src/controller.mjs +1029 -0
  10. package/compiler/src/dist-swap.mjs +76 -0
  11. package/compiler/src/mail-reach.mjs +104 -0
  12. package/compiler/src/policy.mjs +345 -0
  13. package/compiler/src/transpiler.mjs +3094 -0
  14. package/compiler/src/view-name.mjs +21 -0
  15. package/kernel/auth.mjs +1502 -0
  16. package/kernel/cache.mjs +405 -0
  17. package/kernel/collections.mjs +1615 -0
  18. package/kernel/cookies.mjs +474 -0
  19. package/kernel/csrf.mjs +698 -0
  20. package/kernel/db.mjs +703 -0
  21. package/kernel/digest.mjs +360 -0
  22. package/kernel/encrypter.mjs +106 -0
  23. package/kernel/form-request.mjs +106 -0
  24. package/kernel/hash.mjs +113 -0
  25. package/kernel/html-entities.mjs +268 -0
  26. package/kernel/http.mjs +1803 -0
  27. package/kernel/image.mjs +341 -0
  28. package/kernel/mail.mjs +269 -0
  29. package/kernel/mailable.mjs +172 -0
  30. package/kernel/model.mjs +1226 -0
  31. package/kernel/multipart.mjs +161 -0
  32. package/kernel/paginator.mjs +336 -0
  33. package/kernel/php-json.mjs +220 -0
  34. package/kernel/php-serialize.mjs +245 -0
  35. package/kernel/queue.mjs +96 -0
  36. package/kernel/ratelimit.mjs +855 -0
  37. package/kernel/redis.mjs +264 -0
  38. package/kernel/resources.mjs +857 -0
  39. package/kernel/response.mjs +17 -0
  40. package/kernel/router.mjs +276 -0
  41. package/kernel/scheduler.mjs +236 -0
  42. package/kernel/service-adapters.mjs +49 -0
  43. package/kernel/session.mjs +934 -0
  44. package/kernel/storage.mjs +265 -0
  45. package/kernel/str.mjs +445 -0
  46. package/kernel/tests/auth.test.mjs +1074 -0
  47. package/kernel/tests/budget.test.mjs +527 -0
  48. package/kernel/tests/cache.test.mjs +290 -0
  49. package/kernel/tests/collections.test.mjs +534 -0
  50. package/kernel/tests/cookies.test.mjs +302 -0
  51. package/kernel/tests/csrf.test.mjs +597 -0
  52. package/kernel/tests/db.test.mjs +1069 -0
  53. package/kernel/tests/encrypter.test.mjs +90 -0
  54. package/kernel/tests/fixtures/laravel-1337-cookies.json +120 -0
  55. package/kernel/tests/hash.test.mjs +149 -0
  56. package/kernel/tests/http.test.mjs +1182 -0
  57. package/kernel/tests/image.test.mjs +235 -0
  58. package/kernel/tests/login-flow.test.mjs +509 -0
  59. package/kernel/tests/model.test.mjs +324 -0
  60. package/kernel/tests/oracle-app.mjs +32 -0
  61. package/kernel/tests/paginator.test.mjs +132 -0
  62. package/kernel/tests/php-json.test.mjs +263 -0
  63. package/kernel/tests/php-serialize.test.mjs +210 -0
  64. package/kernel/tests/ports.test.mjs +47 -0
  65. package/kernel/tests/ratelimit.test.mjs +975 -0
  66. package/kernel/tests/readiness.test.mjs +155 -0
  67. package/kernel/tests/redis.test.mjs +175 -0
  68. package/kernel/tests/registry-json.test.mjs +28 -0
  69. package/kernel/tests/resources.test.mjs +275 -0
  70. package/kernel/tests/router.test.mjs +281 -0
  71. package/kernel/tests/routes-json.test.mjs +32 -0
  72. package/kernel/tests/services.test.mjs +474 -0
  73. package/kernel/tests/session.test.mjs +709 -0
  74. package/kernel/tests/sqlite-integration.test.mjs +390 -0
  75. package/kernel/tests/storage.test.mjs +133 -0
  76. package/kernel/tests/str.test.mjs +83 -0
  77. package/kernel/tests/upload.test.mjs +193 -0
  78. package/kernel/tests/validation-messages.test.mjs +117 -0
  79. package/kernel/tests/validation.test.mjs +340 -0
  80. package/kernel/tests/validator-hooks.test.mjs +343 -0
  81. package/kernel/tests/validator.test.mjs +805 -0
  82. package/kernel/tests/vendor-lite.test.mjs +39 -0
  83. package/kernel/validation-messages.data.mjs +168 -0
  84. package/kernel/validation-messages.mjs +249 -0
  85. package/kernel/validation.mjs +123 -0
  86. package/kernel/validator.mjs +1538 -0
  87. package/package.json +68 -5
  88. package/shadow/run.mjs +784 -0
  89. package/shadow/scenarios/fresh-app.mjs +60 -0
  90. package/shadow/scenarios/lw-constructs-app.mjs +669 -0
  91. package/src/ports/dbcheck.mjs +22 -0
  92. package/src/worker.mjs +200 -0
  93. package/tools/_root.php +135 -0
  94. package/tools/bench.mjs +129 -0
  95. package/tools/class-dump.php +87 -0
  96. package/tools/compile-view.php +263 -0
  97. package/tools/config-dump.php +192 -0
  98. package/tools/constants-dump.php +46 -0
  99. package/tools/construct-audit.mjs +326 -0
  100. package/tools/constructs.json +1510 -0
  101. package/tools/docs-check.mjs +149 -0
  102. package/tools/event-dump.php +320 -0
  103. package/tools/formrequest-dump.php +229 -0
  104. package/tools/gate.mjs +36 -0
  105. package/tools/http-oracle.php +200 -0
  106. package/tools/init.mjs +73 -0
  107. package/tools/integrations.json +69 -0
  108. package/tools/mailable-views.php +116 -0
  109. package/tools/make-cookie-fixture.mjs +87 -0
  110. package/tools/make-html-entities.mjs +106 -0
  111. package/tools/model-dump.php +198 -0
  112. package/tools/php-auth-oracle.php +701 -0
  113. package/tools/php-cache-oracle.php +166 -0
  114. package/tools/php-crypto-oracle.php +113 -0
  115. package/tools/php-csrf-oracle.php +271 -0
  116. package/tools/php-http-pipeline-oracle.php +358 -0
  117. package/tools/php-json-oracle.php +102 -0
  118. package/tools/php-login-flow-oracle.php +342 -0
  119. package/tools/php-provision.mjs +172 -0
  120. package/tools/php-ratelimit-oracle.php +508 -0
  121. package/tools/php-runtime.mjs +342 -0
  122. package/tools/php-serialize-oracle.php +114 -0
  123. package/tools/php-session-oracle.php +266 -0
  124. package/tools/php-shadow-oracle.php +588 -0
  125. package/tools/policy-dump.php +240 -0
  126. package/tools/ports.mjs +51 -0
  127. package/tools/prerender.php +68 -0
  128. package/tools/readiness.mjs +255 -0
  129. package/tools/registry-json.mjs +22 -0
  130. package/tools/routes-dump.php +27 -0
  131. package/tools/routes-json.mjs +48 -0
  132. package/tools/schedule-dump.php +82 -0
  133. package/tools/schema-dump.php +158 -0
  134. package/tools/validation-messages.php +54 -0
  135. package/tools/vendor-lite.mjs +299 -0
  136. package/tools/verify-s0.php +209 -0
package/build.mjs ADDED
@@ -0,0 +1,2599 @@
1
+ #!/usr/bin/env node
2
+ // build.mjs — Build pipeline (Laravel → Workers artifact)
3
+ //
4
+ // "Booting" happens HERE (build time): the real Laravel app renders pages and
5
+ // exports its route table. The artifact shipped to Cloudflare is request-time
6
+ // static: prerendered HTML + small JS ports. Nothing boots at request time.
7
+ //
8
+ // Route tiers (see README):
9
+ // ports — live JS handlers (registry.json → "ports"), highest priority
10
+ // static — manual page map (registry.json → "static")
11
+ // snapshot — automatic: clean GET routes rendered at build time land on the edge
12
+ // ignore — consciously skipped (registry.json → "ignore")
13
+ //
14
+ // Usage: node build.mjs [--strict] [--scaffold]
15
+
16
+ import { execFileSync } from 'node:child_process';
17
+ import { buildRoutesJson } from './tools/routes-json.mjs';
18
+ import { mergePorts, serializeRegistry } from './tools/registry-json.mjs';
19
+ import { STUB_MARKER, buildStubSource, findStubPorts } from './tools/ports.mjs';
20
+ import { BLOB_DDL, BLOB_TABLE } from './kernel/storage.mjs';
21
+ import { BLOCK, DECLARED, renderReadiness, sweepAppSources } from './tools/readiness.mjs';
22
+ import { compilePolicies } from './compiler/src/policy.mjs';
23
+ import { compileClasses, compileOneMethod } from './compiler/src/classes.mjs';
24
+ import { reachableClasses, reachedMailables } from './compiler/src/mail-reach.mjs';
25
+ import { viewFileName } from './compiler/src/view-name.mjs';
26
+ import { ensureVendorLite } from './tools/vendor-lite.mjs';
27
+ import { ORACLE_PHP, resolvePhp } from './tools/php-runtime.mjs';
28
+ import { provisionPhp } from './tools/php-provision.mjs';
29
+ import { commitDist, discardStaging } from './compiler/src/dist-swap.mjs';
30
+ import {
31
+ cpSync,
32
+ existsSync,
33
+ lstatSync,
34
+ mkdirSync,
35
+ readdirSync,
36
+ readFileSync,
37
+ renameSync,
38
+ rmSync,
39
+ writeFileSync,
40
+ } from 'node:fs';
41
+ import { dirname, join, relative, resolve } from 'node:path';
42
+ import { fileURLToPath } from 'node:url';
43
+
44
+ const pkgDir = dirname(fileURLToPath(import.meta.url));
45
+ let appDir;
46
+ if (existsSync(join(process.cwd(), 'artisan')) && existsSync(join(process.cwd(), 'vendor'))) {
47
+ appDir = process.cwd();
48
+ } else if (existsSync(join(dirname(pkgDir), 'artisan'))) {
49
+ appDir = dirname(pkgDir);
50
+ } else {
51
+ appDir = process.cwd();
52
+ }
53
+ const isNpmMode = appDir !== pkgDir && existsSync(join(appDir, 'artisan'));
54
+ // The build writes into a STAGING directory and swaps it into place only after
55
+ // every artifact landed (see the end of this file). A build that fails halfway
56
+ // used to leave a half-written `dist/` behind - an operator (or a watcher)
57
+ // could then deploy an artifact the build never finished, and the bundler's
58
+ // error named a missing file instead of the skipped route that caused it.
59
+ const distFinal = isNpmMode ? join(appDir, 'dist') : join(pkgDir, 'dist');
60
+ const dist = distFinal + '.building';
61
+ const cacheDir = isNpmMode ? join(appDir, '.cache', 'laravel-workers') : join(pkgDir, '.cache');
62
+ const strict = process.argv.includes('--strict');
63
+ const scaffold = process.argv.includes('--scaffold');
64
+ // The interpreter that boots the app. It is RESOLVED below, once composer.json
65
+ // has been read: the app's own `require.php` decides which installed PHP can
66
+ // parse it, and hard-coding `php` from PATH fails for every user whose default
67
+ // differs from the app's. `PHP` stays a module-level binding so every tool call
68
+ // below uses the same binary.
69
+ let PHP = 'php';
70
+ // The PHP preflight lives below, after `fail` is initialised (calling a
71
+ // `const` before its declaration throws ReferenceError, and a bare catch
72
+ // would swallow it — the preflight would be dead code on exactly the path it
73
+ // exists to handle).
74
+
75
+ // Where the app's PHP dependencies live. An app that runs locally has them in
76
+ // <app>/vendor; a fresh clone has none, and `vendor:ensure` (tools/vendor-lite.mjs)
77
+ // keeps a content-addressed copy instead. Either way the helpers and artisan
78
+ // read the same override, so nothing below has to know which it is.
79
+ let vendorDir = process.env.LW_VENDOR_DIR ? resolve(process.env.LW_VENDOR_DIR) : null;
80
+ if (vendorDir) {
81
+ const ok = existsSync(join(vendorDir, 'autoload.php')) || existsSync(join(vendorDir, 'vendor/autoload.php'));
82
+ if (!ok) fail(`LW_VENDOR_DIR is set to "${process.env.LW_VENDOR_DIR}" but no autoload.php was found there (looked for <dir>/autoload.php and <dir>/vendor/autoload.php)`);
83
+ }
84
+ // Child PHP processes inherit this: artisan needs it, and the tools/*.php helpers
85
+ // read it through tools/_root.php.
86
+ const childEnv = () => ({ ...process.env, LW_APP_ROOT: appDir, ...(vendorDir ? { LW_VENDOR_DIR: vendorDir } : {}) });
87
+
88
+ // One place the operator looks to answer "can my app run at the edge?"
89
+ const findings = [];
90
+ const note = (f) => findings.push(f);
91
+ const relPath = (p) => relative(appDir, p).split('\\').join('/');
92
+
93
+ const fail = (msg) => {
94
+ console.error('BUILD FAILED: ' + msg);
95
+ // The staging directory is thrown away: `dist/` keeps the last build that
96
+ // COMPLETED (a failed build must never leave a half-written dist behind).
97
+ discardStaging(dist);
98
+ // A failed build must never print a green verdict. Failures raised before
99
+ // their own check runs (missing frontend manifest, PHP preflight) would
100
+ // otherwise leave `findings` empty and the banner would read RUNS ON THE
101
+ // EDGE directly under BUILD FAILED. Record the failure as the blocking
102
+ // finding so the verdict is DOES NOT RUN with the reason attached.
103
+ if (!findings.some((f) => f && f.severity === BLOCK)) {
104
+ note({
105
+ severity: BLOCK,
106
+ area: 'config',
107
+ cause: msg.split('\n')[0],
108
+ fix: 'fix the error above, then re-run the build',
109
+ });
110
+ }
111
+ console.error(renderReadiness(findings, { dist: relPath(distFinal) }));
112
+ process.exit(1);
113
+ };
114
+
115
+ // Resolve the interpreter that can boot THIS app.
116
+ //
117
+ // The app's `require.php` is the constraint, not the machine's default: an app
118
+ // declaring `^8.4` cannot be parsed by 8.3 at all, and an older app may not run
119
+ // on 8.4. `resolvePhp` honours PHP_BIN, then `php` from PATH, then any versioned
120
+ // interpreter installed on the machine (Laragon, XAMPP, C:\php, Herd,
121
+ // Homebrew, asdf, phpenv, mise), highest satisfying version first. When nothing
122
+ // satisfies it, the error names every interpreter found and the ways out.
123
+ let phpRequirement = null;
124
+ if (isNpmMode) {
125
+ try {
126
+ phpRequirement = JSON.parse(readFileSync(join(appDir, 'composer.json'), 'utf8')).require?.php || null;
127
+ } catch {
128
+ phpRequirement = null; // no/unreadable composer.json: no constraint to enforce
129
+ }
130
+ }
131
+ let resolved = null;
132
+ try {
133
+ resolved = resolvePhp({ requirement: phpRequirement });
134
+ } catch (e) {
135
+ // Nothing installed can boot the app. When the app declares a requirement and
136
+ // provisioning is allowed, fetch a portable interpreter into the cache rather
137
+ // than refusing to build: "this machine has no PHP 8.4" is not a reason an
138
+ // 8.4 app cannot be built. LW_NO_PHP_PROVISION=1 opts out (offline CI).
139
+ if (phpRequirement && !process.env.LW_NO_PHP_PROVISION) {
140
+ console.log(`• php: no installed interpreter satisfies ${phpRequirement} — provisioning a portable build`);
141
+ try {
142
+ const p = await provisionPhp(phpRequirement);
143
+ resolved = { bin: p.bin, version: p.version, source: p.source };
144
+ console.log(` -> php ${p.version} ${p.cached ? 'reused from cache' : 'downloaded and verified (sha256)'} at ${p.bin}`);
145
+ } catch (e2) {
146
+ fail(e.message + '\n automatic provisioning also failed: ' + e2.message);
147
+ }
148
+ } else {
149
+ fail(e.message);
150
+ }
151
+ }
152
+ PHP = resolved.bin;
153
+ // A build with a different interpreter is valid — it boots the app — but the
154
+ // parity suite is pinned to ORACLE_PHP, so say which one was used. Silence
155
+ // here would let a version-specific divergence look like a compiler bug.
156
+ if (resolved.source !== 'PATH' || resolved.version !== ORACLE_PHP) {
157
+ console.log(
158
+ `• php: ${resolved.version} (${resolved.bin})` +
159
+ (phpRequirement ? ` — app requires ${phpRequirement}` : '') +
160
+ (resolved.version === ORACLE_PHP ? '' : `; parity suite is pinned to ${ORACLE_PHP}`)
161
+ );
162
+ }
163
+ const lwConfig = process.env.LW_CONFIG ? resolve(appDir, process.env.LW_CONFIG) : null;
164
+ if (lwConfig && !existsSync(lwConfig))
165
+ fail(`LW_CONFIG is set to "${process.env.LW_CONFIG}" but that file was not found (resolved: ${lwConfig})`);
166
+ const registryCandidates = [
167
+ ...(lwConfig ? [lwConfig] : []),
168
+ join(appDir, 'registry.json'),
169
+ join(appDir, 'laravel-workers.json'),
170
+ join(appDir, 'edge/registry.json'),
171
+ join(pkgDir, 'registry.json'),
172
+ ];
173
+ let registryPath = null;
174
+ for (const p of registryCandidates) if (existsSync(p)) { registryPath = p; break; }
175
+ if (lwConfig && registryPath === lwConfig) console.log(`• registry: LW_CONFIG -> ${registryPath}`);
176
+ let registry = {};
177
+ if (registryPath) {
178
+ registry = JSON.parse(readFileSync(registryPath, 'utf8'));
179
+ } else {
180
+ registryPath = join(appDir, 'registry.json');
181
+ console.log('• registry: none found — using defaults (autoSnapshot on, no ports/ignore); add registry.json or set LW_CONFIG to declare routes');
182
+ }
183
+ const lwPortsDir = process.env.LW_PORTS_DIR ? resolve(appDir, process.env.LW_PORTS_DIR) : null;
184
+ if (lwPortsDir && !existsSync(lwPortsDir))
185
+ fail(`LW_PORTS_DIR is set to "${process.env.LW_PORTS_DIR}" but that directory was not found (resolved: ${lwPortsDir})`);
186
+ const autoSnapshot = registry.autoSnapshot !== false;
187
+ const staticMap = Object.entries(registry.static || {});
188
+ const portEntries = Object.entries(registry.ports || {});
189
+ const ignoreMap = Object.entries(registry.ignore || {});
190
+ const viewMap = Object.entries(registry.views || {});
191
+ if (!vendorDir && !existsSync(join(appDir, 'vendor/autoload.php'))) {
192
+ // No vendor/ and no override: provision a composer-free vendor-lite cache
193
+ // (fetch dist zips + write our own autoloader - no composer install).
194
+ try {
195
+ const v = await ensureVendorLite(appDir, { silent: true });
196
+ vendorDir = v.dir;
197
+ console.log(` -> vendor: provisioned via composer-free vendor-lite at ${v.dir}`);
198
+ } catch (e) {
199
+ note({
200
+ route: null,
201
+ area: 'config',
202
+ severity: BLOCK,
203
+ cause: 'the app has no vendor/ (and LW_VENDOR_DIR is not set)',
204
+ fix: 'the build boots your app to read its routes, config and schema, so it needs the PHP dependencies',
205
+ paths: [
206
+ { tier: 'free', what: 'the laravel-workers vendor-lite installer fetches the needed packages directly (no composer install)' },
207
+ { tier: 'free', what: 'or reuse a cached tree: npx laravel-workers vendor:ensure && LW_VENDOR_DIR=<printed dir> npx laravel-workers build --strict' },
208
+ ],
209
+ });
210
+ fail(`vendor provisioning failed: ${e.message}`);
211
+ }
212
+ }
213
+ // A Vite app writes public/build/manifest.json. A Mix/webpack app writes
214
+ // public/mix-manifest.json. A standalone esbuild (BookStack: public/dist) writes
215
+ // no manifest at all — the build can still proceed when the pages are declared
216
+ // in registry.json `static`. Failing on "public/build" alone made whole apps
217
+ // untestable before the first real decision. The order is Vite-first: the path
218
+ // the build WOULD write if its own `npm run build` ran.
219
+ {
220
+ const manifestCandidates = [
221
+ join(appDir, 'public/build/manifest.json'),
222
+ join(appDir, 'public/dist/manifest.json'),
223
+ join(appDir, 'public/mix-manifest.json'),
224
+ ];
225
+ const manifestPath = manifestCandidates.find((p) => existsSync(p));
226
+ if (!manifestPath) {
227
+ if (Object.keys(registry.static || {}).length) {
228
+ console.log(
229
+ '\u2022 manifest: none of the usual frontend manifests found (' +
230
+ manifestCandidates.map((p) => relPath(p)).join(', ') +
231
+ ') — but registry.json has "static", so pages declared there count as handled'
232
+ );
233
+ } else {
234
+ fail(
235
+ 'no frontend manifest found — looked for ' +
236
+ manifestCandidates.map((p) => relPath(p)).join(', ') +
237
+ ' — run the app\'s frontend build (npm run build) first, or declare pages in registry.json "static"'
238
+ );
239
+ }
240
+ } else {
241
+ console.log(`\u2022 manifest: ${relPath(manifestPath)}`);
242
+ }
243
+ }
244
+
245
+ // ---------- 1. export routes from the Laravel app ----------
246
+ console.log('• exporting routes via `php artisan route:list --json` (booted through the resolved autoloader)');
247
+ let listed;
248
+ try {
249
+ const rawRoutes = execFileSync(PHP, [join(pkgDir, 'tools', 'routes-dump.php')], { cwd: appDir, env: childEnv() }).toString('utf8');
250
+ try {
251
+ listed = JSON.parse(rawRoutes);
252
+ } catch {
253
+ // A Laravel boot failure (missing APP_KEY, a PHP-version parse error, an
254
+ // invalid config file) is rendered by the console kernel into STDOUT
255
+ // while the process still exits 0 — the real cause is in that text, not
256
+ // on stderr.
257
+ const tail = rawRoutes.trim() ? rawRoutes.trim().split('\n').slice(-15).map((l) => ' ' + l).join('\n') : ' (empty)';
258
+ fail(
259
+ 'route export failed — the app did not return the JSON route table, so it failed to boot\n' +
260
+ ' PHP output:\n' + tail +
261
+ '\n the build cannot continue without the route table — fix the error above, then re-run the build'
262
+ );
263
+ }
264
+ } catch (e) {
265
+ // PHP compile/boot errors print to STDOUT even when the process exits
266
+ // non-zero, so both streams matter here. Reporting only stderr shipped a
267
+ // generic "missing env vars" hint for what was really a PHP parse error.
268
+ const stderr = e.stderr ? e.stderr.toString().trim() : '';
269
+ const stdout = e.stdout ? e.stdout.toString().trim() : '';
270
+ const outTail = stdout ? stdout.split('\n').slice(-15).join('\n') : '';
271
+ const errTail = stderr ? stderr.split('\n').slice(-15).join('\n') : '';
272
+ const combined = [errTail, outTail].filter(Boolean).join('\n');
273
+ const errDetail = combined ? '\n PHP output:\n ' + combined.replace(/\n/g, '\n ') : '';
274
+ note({
275
+ severity: BLOCK,
276
+ area: 'route',
277
+ cause: 'the app failed to boot during route export' + (combined ? ': ' + combined.split('\n').pop().trim() : ''),
278
+ fix: 'check that the app has a .env with APP_KEY, DB connection and all required vars; then re-run',
279
+ });
280
+ fail(
281
+ 'route export crashed' + errDetail +
282
+ '\n the build cannot continue without the route table — this usually means\n' +
283
+ ' the app failed to boot (missing env vars, DB connection, etc.)\n' +
284
+ ' -> fix the root cause in the app, then re-run the build'
285
+ );
286
+ }
287
+ // Laravel only reports `path` for closure routes — controller-action routes
288
+ // (Route::get('/x', [XController::class, 'm']), Route::resource, invokable
289
+ // controllers) come back with path === null even though they live in routes/.
290
+ const isAppRoute = (r) => {
291
+ const p = String(r.path || '');
292
+ if (p) return p.startsWith('routes/');
293
+ const action = String(r.action || '');
294
+ return action !== '' && action !== 'Closure';
295
+ };
296
+ const appRoutes = listed.filter(isAppRoute);
297
+ const normalize = (u) => (u === '/' || u === '' ? '/' : '/' + String(u).replace(/^\/+|\/+$/g, ''));
298
+ const routes = [...new Set(appRoutes.map((r) => normalize(r.uri)))].sort();
299
+ const methodsFor = new Map();
300
+ const metaFor = new Map(); // 'METHOD /uri' -> { name, domain }
301
+ for (const r of appRoutes) {
302
+ const uri = normalize(r.uri);
303
+ const set = methodsFor.get(uri) || new Set();
304
+ const methods = String(r.method || 'GET').split('|');
305
+ for (const m of methods) if (m) set.add(m);
306
+ methodsFor.set(uri, set);
307
+ for (const m of methods) {
308
+ if (!m) continue;
309
+ const key = m.toUpperCase() + ' ' + uri;
310
+ if (!metaFor.has(key)) metaFor.set(key, { name: r.name ?? null, domain: r.domain ?? null, action: r.action ?? null, middleware: Array.isArray(r.middleware) ? r.middleware : null });
311
+ }
312
+ }
313
+ const otherMethods = (route) =>
314
+ [...(methodsFor.get(route) || new Set())].filter((m) => m !== 'GET' && m !== 'HEAD');
315
+ // first non-null name/domain/middleware across the route's methods (they share a
316
+ // uri in the table; middleware is preferred from GET like middlewareForRoute).
317
+ // The emitted `middleware` is what kernel/http.mjs uses to decide whether the
318
+ // `web` group (EncryptCookies → StartSession → CSRF) and route middleware
319
+ // (auth/guest/throttle) run — omitting it silently downgraded every compiled
320
+ // route to the bare, session-less path.
321
+ const metaForRoute = (route) => {
322
+ let name = null;
323
+ let domain = null;
324
+ let middleware = null;
325
+ for (const m of methodsFor.get(route) || []) {
326
+ const meta = metaFor.get(m + ' ' + route);
327
+ if (!meta) continue;
328
+ if (name == null) name = meta.name;
329
+ if (domain == null) domain = meta.domain;
330
+ if (middleware == null && meta.middleware) middleware = meta.middleware;
331
+ }
332
+ return { name, domain, middleware };
333
+ };
334
+ // route:list action for a route (prefer GET — the only method controller
335
+ // transpilation supports today).
336
+ const actionForRoute = (route) => {
337
+ const getMeta = metaFor.get('GET ' + route);
338
+ if (getMeta && getMeta.action) return getMeta.action;
339
+ for (const m of methodsFor.get(route) || []) {
340
+ const meta = metaFor.get(m + ' ' + route);
341
+ if (meta && meta.action) return meta.action;
342
+ }
343
+ return null;
344
+ };
345
+ // route:list middleware for a route (prefer GET — see above).
346
+ const middlewareForRoute = (route) => {
347
+ const getMeta = metaFor.get('GET ' + route);
348
+ if (getMeta && getMeta.middleware) return getMeta.middleware;
349
+ for (const m of methodsFor.get(route) || []) {
350
+ const meta = metaFor.get(m + ' ' + route);
351
+ if (meta && meta.middleware) return meta.middleware;
352
+ }
353
+ return null;
354
+ };
355
+ // The verb a uri's "main" handler answers: GET when present, else the first
356
+ // non-HEAD verb (POST for form-only routes). Extra verbs (e.g. Breeze's
357
+ // GET+POST /login) are compiled as separate per-verb handlers below.
358
+ const mainVerbFor = (route) => {
359
+ const set = methodsFor.get(route) || new Set();
360
+ if (set.has('GET')) return 'GET';
361
+ const others = [...set].filter((m) => m !== 'HEAD');
362
+ return others.length ? others[0] : null;
363
+ };
364
+ const extraVerbsFor = (route) =>
365
+ [...(methodsFor.get(route) || new Set())].filter((m) => m !== 'HEAD' && m !== mainVerbFor(route));
366
+ const metaForVerb = (route, verb) => metaFor.get(String(verb).toUpperCase() + ' ' + route) || null;
367
+ // Middleware kernel/http.mjs actually honours. auth/guest run on top of the web
368
+ // group; numeric `throttle:n[,m]` runs against the D1-backed `database` cache.
369
+ const HONOURED_MIDDLEWARE = new Set(['web', 'api', 'substitute_bindings', 'bindings', 'auth', 'auth.basic', 'guest']);
370
+ const isHonouredMiddleware = (m) => {
371
+ const str = String(m);
372
+ const name = str.split(':')[0];
373
+ if (HONOURED_MIDDLEWARE.has(name)) return true;
374
+ if (name !== 'throttle') return false;
375
+ const spec = str.slice('throttle'.length).replace(/^:/, '');
376
+ return spec === '' || /^\d+(,\d+)?$/.test(spec); // numeric spec only
377
+ };
378
+
379
+ const MIDDLEWARE_FIX = (m, route) => {
380
+ const name = String(m).split(':')[0];
381
+ const ignore = '{\"ignore\": {\"' + route + '\": \"<why the edge does not serve it>\"}}';
382
+ switch (name) {
383
+ case 'verified': return 'email verification needs a signed URL the edge cannot mint the same way -- add a port, or ' + ignore;
384
+ case 'signed': return 'signed URLs are verified against app.key (a port can do it with WebCrypto) -- add a port, or ' + ignore;
385
+ case 'password.confirm': return 'the password-confirm window is session state the compiled rung does not port -- add a port, or ' + ignore;
386
+ case 'can': return 'a route-level `can:` check needs the policy at the route stage -- call `$this->authorize()` inside the controller instead (that compiles), or add a port';
387
+ case 'throttle': return 'a NAMED rate limiter lives in AppServiceProvider and the build cannot read it -- use a numeric throttle (e.g. throttle:60,1) or ' + ignore;
388
+ case 'cors': return 'CORS headers are added by a port or by the Cloudflare dashboard (Transform Rules) -- the compiled rung does not add them';
389
+ default: return 'add a port for this route (build --scaffold), or ' + ignore;
390
+ }
391
+ };
392
+ console.log(` app routes (${routes.length}): ${routes.join(' ')}`);
393
+ const skippedRoutes = [
394
+ ...new Set(listed.filter((r) => !isAppRoute(r)).map((r) => normalize(r.uri))),
395
+ ];
396
+ if (skippedRoutes.length) {
397
+ const shown = skippedRoutes.slice(0, 5).join(', ');
398
+ console.log(
399
+ ` skipped ${skippedRoutes.length} route(s) defined outside routes/ (not part of the edge build): ${shown}${
400
+ skippedRoutes.length > 5 ? ', …' : ''
401
+ }`
402
+ );
403
+ }
404
+
405
+ // ---------- 2. prerender helper (boots the real app, build time only) ----------
406
+ const originRe = /(?:https?:)?\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?\//g;
407
+
408
+ // The framework can write an error page to stdout around our payload (console
409
+ // exception renderers bypass PHP output buffering), so prerender.php terminates
410
+ // its stdout with a marker and the JSON that follows it.
411
+ const PRERENDER_MARKER = '---LW-PRERENDER-JSON---';
412
+
413
+ const isRuntimeBindingError = (reason) => /Database connection \[[^\]]+\] not configured|D1 (?:database configuration|Worker driver) requires (?:a|an) "/.test(String(reason));
414
+ const prerender = (route) => {
415
+ let out;
416
+ try {
417
+ out = execFileSync(PHP, ['-d', 'display_errors=stderr', join(pkgDir, 'tools/prerender.php'), route], {
418
+ cwd: appDir,
419
+ env: childEnv(),
420
+ }).toString('utf8');
421
+ } catch (e) {
422
+ const stderr = String(e.stderr || '').trim();
423
+ const stdout = String(e.stdout || '').trim();
424
+ const detail = (stderr || stdout || String(e.message || '')).slice(-2000);
425
+ fail(`prerender crashed for ${route}${detail ? `:\n${detail}` : ''}`);
426
+ }
427
+ const idx = out.lastIndexOf(PRERENDER_MARKER);
428
+ const jsonText = idx >= 0 ? out.slice(idx + PRERENDER_MARKER.length) : out;
429
+ try {
430
+ return JSON.parse(jsonText);
431
+ } catch {
432
+ fail(`prerender returned non-JSON for ${route}: ${jsonText.trim().slice(0, 200)}`);
433
+ }
434
+ };
435
+
436
+ // ---------- 3. dist skeleton + prerendered pages ----------
437
+ const hotFile = join(appDir, 'public/hot');
438
+ const hotAside = join(appDir, 'public/hot.edge-build-aside');
439
+ if (existsSync(hotFile)) {
440
+ renameSync(hotFile, hotAside);
441
+ console.log(' (moved public/hot aside for the build)');
442
+ }
443
+ // Clean dist/. On Windows a running `wrangler dev` (or editor) can hold a handle on
444
+ // dist/ or a subdirectory: file deletion still works, so fall back to wiping the
445
+ // contents and keeping the locked directory shells — the build rewrites everything
446
+ // fresh and the watcher picks the changes up.
447
+ for (const entry of readdirSync(appDir)) {
448
+ if (entry.startsWith('dist.old-')) {
449
+ try {
450
+ rmSync(join(appDir, entry), { recursive: true, force: true, maxRetries: 2, retryDelay: 100 });
451
+ } catch {
452
+ /* still locked — try again next build */
453
+ }
454
+ }
455
+ }
456
+ try {
457
+ rmSync(dist, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
458
+ } catch {
459
+ if (existsSync(dist)) {
460
+ for (const entry of readdirSync(dist)) {
461
+ try {
462
+ rmSync(join(dist, entry), { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
463
+ } catch {
464
+ console.log(
465
+ ` (warning: ${relative(appDir, join(dist, entry))} is locked by a running process — restart the dev server if build output looks stale)`
466
+ );
467
+ }
468
+ }
469
+ }
470
+ }
471
+ mkdirSync(join(dist, 'public'), { recursive: true });
472
+ mkdirSync(join(dist, 'src'), { recursive: true });
473
+
474
+ const writePage = (file, html) => {
475
+ const rel = html.replace(originRe, '/');
476
+ const outPath = join(dist, 'public', file);
477
+ mkdirSync(dirname(outPath), { recursive: true });
478
+ writeFileSync(outPath, rel);
479
+ return { bytes: rel.length, fixed: (html.match(originRe) || []).length };
480
+ };
481
+
482
+ const snapshots = [];
483
+ const partials = [];
484
+ const needsPort = [];
485
+ const uncompiledExtraNonPost = [];
486
+
487
+ try {
488
+ // 3a. manual static pages (registry.json → "static")
489
+ for (const [route, file] of staticMap) {
490
+ const res = prerender(route);
491
+ if (res.status < 200 || res.status >= 300) {
492
+ const rawDetail = res.error ? String(res.error) : (res.body ? String(res.body) : '');
493
+ const detail = res.error ? `: ${res.error}` : (res.body ? `: ${String(res.body).slice(0, 150).replace(/\n/g, ' ')}` : '');
494
+ note({ route, area: 'route', severity: BLOCK, cause: `static entry "${route}" returned HTTP ${res.status}${detail}`, fix: rawDetail.includes('key:generate') ? 'run `php artisan key:generate` in the app' : isRuntimeBindingError(rawDetail) ? 'this static entry reads a D1 binding the build cannot reach -- a static entry must prerender clean, so give the app a local sqlite fallback for the build (config/database.php) or make the route dynamic' : rawDetail.includes('database') ? 'prepare the DB: touch database/database.sqlite && php artisan migrate --force (the build prerenders every page it serves)' : 'a deployed build will prerender this route too -- fix the exception the app throws for bare GET `/` first' });
495
+ fail(`static route ${route} returned HTTP ${res.status}${detail}`);
496
+ }
497
+ const { bytes, fixed } = writePage(file, res.body);
498
+ console.log(`• static ${route} -> public/${file} (${bytes} bytes, ${fixed} URL(s) made relative)`);
499
+ if (otherMethods(route).length) partials.push({ route, methods: otherMethods(route) });
500
+ }
501
+
502
+ // 3b. auto-snapshot: clean GET routes appear on the edge on the next deploy
503
+ const handled = new Set([
504
+ ...staticMap.map(([r]) => r),
505
+ ...portEntries.map(([r]) => r),
506
+ ...viewMap.map(([r]) => r),
507
+ ...ignoreMap.map(([r]) => r),
508
+ ]);
509
+ for (const route of routes) {
510
+ if (handled.has(route)) continue;
511
+ if (!autoSnapshot) {
512
+ needsPort.push({ route, reason: 'unhandled (autoSnapshot: false)' });
513
+ continue;
514
+ }
515
+ const methods = methodsFor.get(route) || new Set();
516
+ const others = otherMethods(route);
517
+ if (!methods.has('GET')) {
518
+ needsPort.push({ route, reason: `no GET (${[...methods].join(', ') || '?'})` });
519
+ continue;
520
+ }
521
+ if (route.includes('{')) {
522
+ needsPort.push({ route, reason: 'dynamic segment — needs a port' });
523
+ continue;
524
+ }
525
+ // A controller route of the APP is compiled, not snapshotted: the build-time
526
+ // page is the fallback for a route the build cannot compile. Snapshotting
527
+ // it answered a stale page for anything the request decides (a query
528
+ // string, a row created during the run) while the compiled controller was
529
+ // never reached - the shadow compared the baked page and called it parity.
530
+ //
531
+ // A FRAMEWORK controller is different: `Illuminate\...` is not the app's
532
+ // code and the build never compiles it (the composer autoload cannot even
533
+ // resolve it), so its snapshot is the right answer - Laravel's own `/up`
534
+ // health route is exactly that.
535
+ {
536
+ const action = actionForRoute(route);
537
+ const actionClass = action && action.includes('@') ? action.slice(0, action.lastIndexOf('@')) : action;
538
+ const isAppController =
539
+ !!actionClass && actionClass !== 'Closure' && !/^Illuminate\\/.test(actionClass);
540
+ if (isAppController) {
541
+ needsPort.push({ route, reason: 'a controller serves it — compiled, not snapshotted' });
542
+ continue;
543
+ }
544
+ }
545
+ const res = prerender(route);
546
+ const isHtml = String(res.content_type || '').includes('text/html');
547
+ if (res.status >= 200 && res.status < 300 && isHtml) {
548
+ const file = route === '/' ? 'index.html' : route.slice(1) + '/index.html';
549
+ const { bytes, fixed } = writePage(file, res.body);
550
+ snapshots.push(route);
551
+ console.log(`• snapshot ${route} -> public/${file} (${bytes} bytes, ${fixed} URL(s) made relative)`);
552
+ if (others.length) partials.push({ route, methods: others });
553
+ } else if (res.status >= 200 && res.status < 300) {
554
+ needsPort.push({ route, reason: `HTTP ${res.status} non-HTML (${res.content_type || '?'})` });
555
+ } else {
556
+ const to = res.location ? ` -> ${res.location}` : '';
557
+ const why = res.error ? `: ${res.error}` : '';
558
+ needsPort.push({ route, reason: `HTTP ${res.status}${to}${why}` });
559
+ }
560
+ }
561
+ } finally {
562
+ if (existsSync(hotAside)) renameSync(hotAside, hotFile);
563
+ }
564
+
565
+ // ---------- 2b. runtime config (compiled HTTP kernel) ----------
566
+ // kernel/http.mjs reads a FLAT dotted config. Anything the compiled rung cannot
567
+ // honour fails the build here instead of producing a silently-degraded worker.
568
+ console.log('• dumping runtime config via `tools/config-dump.php`');
569
+ let runtimeConfig;
570
+ try {
571
+ const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'config-dump.php')], {
572
+ cwd: appDir,
573
+ env: childEnv(),
574
+ maxBuffer: 1 << 24,
575
+ }).toString('utf8');
576
+ try {
577
+ runtimeConfig = JSON.parse(raw);
578
+ } catch {
579
+ const tail = raw.trim() ? raw.trim().split('\n').slice(-15).map((l) => ' ' + l).join('\n') : ' (empty)';
580
+ note({
581
+ severity: BLOCK,
582
+ area: 'config',
583
+ cause: 'runtime config dump failed — the app did not return the JSON config (it failed to boot)',
584
+ fix: 'the PHP error below is the cause; check APP_KEY, session.driver, and config file validity',
585
+ });
586
+ fail(
587
+ 'config dump failed — the app did not return the JSON runtime config, so it failed to boot\n' +
588
+ ' PHP output:\n' + tail
589
+ );
590
+ }
591
+ } catch (e) {
592
+ const msg = String(e.stderr || e.message || '').trim().slice(0, 800);
593
+ note({
594
+ severity: BLOCK,
595
+ area: 'config',
596
+ cause: 'runtime config dump failed' + (msg ? ': ' + msg : ''),
597
+ fix: 'check APP_KEY, session.driver, and config file validity; ensure required keys are present and valid JSON',
598
+ });
599
+ fail('config dump failed: ' + msg);
600
+ }
601
+ {
602
+ const problems = [];
603
+ const problem = (cause, fix) => { problems.push(cause); note({ route: null, area: 'config', severity: BLOCK, cause, fix }); };
604
+ if (!runtimeConfig['app.key']) problem('app.key is empty', 'run `php artisan key:generate` (the edge signs cookies and sessions with it)');
605
+ const driver = runtimeConfig['session.driver'];
606
+ if (driver !== 'database' && driver !== 'cookie') {
607
+ problem(`session.driver '${driver}' is not supported by the compiled rung (database/cookie only)`);
608
+ }
609
+ if (runtimeConfig['session.encrypt']) {
610
+ problem('SESSION_ENCRYPT=true (EncryptedStore) is not supported by the compiled rung yet');
611
+ }
612
+ if (runtimeConfig['cookies.serialize']) {
613
+ problem('EncryptCookies::$serialize must be false (cookie serialization is not ported)');
614
+ }
615
+ if ((runtimeConfig['hashing.driver'] || 'bcrypt') !== 'bcrypt') {
616
+ problem(`hashing.driver '${runtimeConfig['hashing.driver']}' is not supported (bcrypt only)`);
617
+ }
618
+ const thirdPartyGuards = Object.entries(runtimeConfig)
619
+ .filter(([k, v]) => k.startsWith('auth.guards.') && v && v.driver && v.driver !== 'session')
620
+ .map(([k, v]) => `${k} (driver '${v.driver}')`);
621
+ if (thirdPartyGuards.length) {
622
+ problem(`guard(s) with a non-session driver: ${thirdPartyGuards.join(', ')} (only the session guard is ported)`);
623
+ }
624
+ if (problems.length) {
625
+ fail('unsupported runtime config (the compiled rung would diverge from PHP):\n - ' + problems.join('\n - '));
626
+ }
627
+ writeFileSync(
628
+ join(dist, 'src', 'config.generated.mjs'),
629
+ '// generated by build.mjs — do not edit\n' + 'export default ' + JSON.stringify(runtimeConfig, null, 2) + ';\n'
630
+ );
631
+ console.log(` -> dist/src/config.generated.mjs (session: ${driver}, guard: ${runtimeConfig['auth.defaults.guard']})`);
632
+ }
633
+
634
+ // The CORS paths (config/cors.php): HandleCors adds the CORS headers to the
635
+ // responses of the paths it matches, and answers the preflight with a 204.
636
+ // The compiled worker does not add the CORS headers, so the routes under a
637
+ // CORS path are skipped (a silent answer without the headers would break the
638
+ // browser's CORS - a divergence the build must not ship).
639
+ const corsPaths = (() => {
640
+ const raw = runtimeConfig['cors.paths'];
641
+ if (!raw || typeof raw !== 'object') return [];
642
+ const out = [];
643
+ for (const value of Object.values(raw)) {
644
+ if (Array.isArray(value)) {
645
+ for (const p of value) if (typeof p === 'string') out.push(p);
646
+ } else if (typeof value === 'string') {
647
+ out.push(value);
648
+ }
649
+ }
650
+ return out;
651
+ })();
652
+ // Laravel's Str::is(): the pattern may end with '*' (a prefix match).
653
+ const corsPathFor = (uri) => {
654
+ const clean = (s) => '/' + String(s == null ? '' : s).replace(/^\/+/, '').replace(/\/+$/, '');
655
+ const path = clean(uri);
656
+ for (const pattern of corsPaths) {
657
+ const p = clean(pattern);
658
+ if (p === '/') return pattern;
659
+ if (p.endsWith('*')) {
660
+ if (path.startsWith(p.slice(0, -1))) return pattern;
661
+ } else if (path === p) {
662
+ return pattern;
663
+ }
664
+ }
665
+ return null;
666
+ };
667
+
668
+ // The routes that dispatch a queued job, and the cron triggers the schedule
669
+ // produced: the queue check at the end reads both.
670
+ const queuePushes = [];
671
+ let cronTriggers = [];
672
+
673
+ // ---------- 3b-bis. the app's schedule -> Cloudflare Cron Triggers ----------
674
+ // The edge cannot run an artisan command, but it can *trigger* it: the schedule
675
+ // becomes a cron trigger (the padanan — Free allows 5 triggers per account) and
676
+ // the due event is queued for the app runtime that still exists.
677
+ {
678
+ console.log('• dumping the app schedule via `tools/schedule-dump.php`');
679
+ let schedule = { events: [] };
680
+ try {
681
+ const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'schedule-dump.php')], {
682
+ cwd: appDir,
683
+ env: childEnv(),
684
+ encoding: 'utf8',
685
+ maxBuffer: 16 * 1024 * 1024,
686
+ }).toString('utf8');
687
+ schedule = JSON.parse(raw);
688
+ } catch (e) {
689
+ console.log(
690
+ ` schedule dump failed — the artifact ships an empty schedule: ${String(e.stderr || e.message || '')
691
+ .trim()
692
+ .slice(0, 200)}`
693
+ );
694
+ }
695
+ const events = Array.isArray(schedule.events) ? schedule.events : [];
696
+ writeFileSync(
697
+ join(dist, 'src', 'schedule.generated.mjs'),
698
+ '// generated by build.mjs — do not edit\n' +
699
+ 'export const events = ' + JSON.stringify(events, null, 2) + ';\n' +
700
+ 'export default { events };\n'
701
+ );
702
+ // The Cloudflare cron trigger list (5 triggers per account on Free).
703
+ const triggers = events.filter((e) => e && e.cron_triggerable && e.expression).map((e) => e.expression);
704
+ const uniqueTriggers = [...new Set(triggers)];
705
+ cronTriggers = uniqueTriggers;
706
+ writeFileSync(
707
+ join(dist, 'cron.json'),
708
+ JSON.stringify({ crons: uniqueTriggers, events: events.map((e) => ({ name: e.name, expression: e.expression })) }, null, 2) + '\n'
709
+ );
710
+ const notTriggerable = events.filter((e) => e && !e.cron_triggerable);
711
+ console.log(
712
+ ` -> dist/src/schedule.generated.mjs (${events.length} event(s), ${uniqueTriggers.length} cron trigger(s))`
713
+ );
714
+ if (uniqueTriggers.length) {
715
+ console.log(
716
+ ` -> dist/cron.json (add the crons to wrangler.jsonc: {"triggers":{"crons":[${uniqueTriggers
717
+ .map((c) => JSON.stringify(c))
718
+ .join(', ')}]}})`
719
+ );
720
+ }
721
+ for (const e of notTriggerable) {
722
+ console.log(` ! ${e.name}: ${e.reason || 'not triggerable at the edge'}`);
723
+ }
724
+ }
725
+
726
+ // Eloquent auth providers name their model as a string; the compiled rung needs the
727
+ // generated Model class, so those FQCNs are always dumped into models.generated.mjs.
728
+ const authProviderFqcns = Object.entries(runtimeConfig)
729
+ .filter(([k, v]) => k.startsWith('auth.providers.') && v && v.driver === 'eloquent' && typeof v.model === 'string')
730
+ .map(([, v]) => v.model);
731
+
732
+ // ---------- 3c. compile view controllers ----------
733
+ const slugifyRoute = (route) =>
734
+ route.replace(new RegExp('^/'), '').replace(/[{}]/g, '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'home';
735
+
736
+ // Does the app bind a Cloudflare product under a given name? `product` is the
737
+ // wrangler key ('r2_buckets' | 'd1_databases'). `wrangler.jsonc` is JSON with
738
+ // comments, so the comments are stripped before parsing.
739
+ function hasBinding(appRoot, product, name) {
740
+ const jsonc = join(appRoot, 'wrangler.jsonc');
741
+ if (existsSync(jsonc)) {
742
+ try {
743
+ const text = readFileSync(jsonc, 'utf8').replace(/^\s*\/\/.*$/gm, '');
744
+ const cfg = JSON.parse(text);
745
+ const list = cfg[product];
746
+ if (Array.isArray(list) && list.some((b) => b && b.binding === name)) return true;
747
+ } catch {
748
+ /* a wrangler.jsonc we cannot parse is not a binding we can rely on */
749
+ }
750
+ }
751
+ const toml = join(appRoot, 'wrangler.toml');
752
+ if (existsSync(toml)) {
753
+ try {
754
+ const text = readFileSync(toml, 'utf8');
755
+ // Every [[product]] block (up to the next table): does one bind the name?
756
+ return text
757
+ .split(new RegExp('\\[\\[' + product + '\\]\\]'))
758
+ .slice(1)
759
+ .some((b) => new RegExp('binding\\s*=\\s*"' + name + '"').test(b.split(/\n\s*\[/)[0]));
760
+ } catch {
761
+ /* ignore */
762
+ }
763
+ }
764
+ return false;
765
+ }
766
+
767
+ mkdirSync(join(dist, 'src', 'controllers'), { recursive: true });
768
+ mkdirSync(join(dist, 'src', 'views'), { recursive: true });
769
+ mkdirSync(join(dist, 'src', 'contexts'), { recursive: true });
770
+
771
+ const controllerByRoute = new Map();
772
+
773
+ for (const [route, view] of viewMap) {
774
+ const slug = slugifyRoute(route);
775
+ const dumpPath = join(cacheDir, view + '.compiled.json');
776
+ console.log(`compiling view ${view} for ${route}`);
777
+ execFileSync(PHP, [join(pkgDir, 'tools', 'compile-view.php'), view, dumpPath, route], {
778
+ cwd: appDir,
779
+ env: childEnv(),
780
+ });
781
+ const dump = JSON.parse(readFileSync(dumpPath, 'utf8'));
782
+ if (!dump || typeof dump.compiled !== 'string') fail('compile-view produced no compiled PHP for ' + route);
783
+
784
+ const viewModulePath = join(dist, 'src', 'views', view + '.mjs');
785
+ execFileSync('node', [join(pkgDir, 'compiler', 'bin', 'transpile.mjs'), dumpPath, '--json', '--out', viewModulePath], { cwd: appDir });
786
+
787
+ const contextPath = join(dist, 'src', 'contexts', view + '.mjs');
788
+ writeFileSync(contextPath, 'export default ' + JSON.stringify(dump.context) + ';\n');
789
+
790
+ const controllerPath = join(dist, 'src', 'controllers', slug + '.mjs');
791
+ writeFileSync(
792
+ controllerPath,
793
+ [
794
+ "import main from '../views/" + view + ".mjs';",
795
+ "import context from '../contexts/" + view + ".mjs';",
796
+ "import { buildRt } from '../runtime/rt-from-context.mjs';",
797
+ "import { html } from '../../kernel/response.mjs';",
798
+ '',
799
+ 'export default function handle(ctx, env) {',
800
+ ' const rt = buildRt(context, { strict: true });',
801
+ ' main(rt);',
802
+ ' return html(rt.__output);',
803
+ '}',
804
+ '',
805
+ ].join('\n')
806
+ );
807
+
808
+ controllerByRoute.set(route, slug);
809
+ console.log(` -> dist/src/controllers/${slug}.mjs`);
810
+ }
811
+
812
+ // ---------- 3c1. compile error views ----------
813
+ // Laravel renders `errors::<code>`: the app's resources/views/errors/<code>
814
+ // when it exists, else the FRAMEWORK's own view
815
+ // (vendor/laravel/framework/src/Illuminate/Foundation/Exceptions/views - the
816
+ // Handler registers that namespace per render, RegisterErrorViewPaths). Both
817
+ // are Blade, so both compile here; a status with neither keeps the
818
+ // status-only page (and the shadow reports it as a divergence).
819
+ const errorViewDir = join(appDir, 'resources', 'views', 'errors');
820
+ const appErrorCodes = existsSync(errorViewDir)
821
+ ? readdirSync(errorViewDir)
822
+ .filter((f) => f.endsWith('.blade.php'))
823
+ .map((f) => f.replace(/\.blade\.php$/, ''))
824
+ .sort()
825
+ : [];
826
+ // The framework's error views EXTEND `errors::minimal`, which extends
827
+ // `errors::layout`: the whole closure compiles, so the layout is in the list.
828
+ const frameworkErrorCodes = [
829
+ '401', '402', '403', '404', '419', '429', '500', '503',
830
+ 'layout', 'minimal',
831
+ ];
832
+ const errorViewCodes = [...new Set([...appErrorCodes, ...frameworkErrorCodes])].sort();
833
+ // Which of the codes come from the framework (the app did not override them):
834
+ // their view NAME is the namespaced one.
835
+ const frameworkOnly = new Set(errorViewCodes.filter((c) => !appErrorCodes.includes(c)));
836
+ const errorViewImports = [];
837
+ const errorViewCases = [];
838
+ mkdirSync(cacheDir, { recursive: true });
839
+ if (errorViewCodes.length) {
840
+ console.log(`• error views: compiling ${errorViewCodes.length} (${errorViewCodes.join(', ')})`);
841
+ mkdirSync(join(dist, 'src', 'views'), { recursive: true });
842
+ mkdirSync(join(dist, 'src', 'contexts'), { recursive: true });
843
+ for (const code of errorViewCodes) {
844
+ const viewName = frameworkOnly.has(code) ? 'errors::' + code : 'errors.' + code;
845
+ // The name the compiled Blade calls back (`$__env->make('errors::minimal')`)
846
+ // and the name the renderer keys on differ for the framework views: the
847
+ // runtime registry uses the `errors::<name>` form the source writes.
848
+ const registryName = frameworkOnly.has(code) ? 'errors::' + code : viewName;
849
+ const dumpPath = join(cacheDir, 'errors.' + code + '.compiled.json');
850
+ console.log(` compiling error view ${viewName}`);
851
+ let dump;
852
+ try {
853
+ execFileSync(PHP, [join(pkgDir, 'tools', 'compile-view.php'), viewName, dumpPath, '/'], {
854
+ cwd: appDir,
855
+ env: childEnv(),
856
+ });
857
+ dump = JSON.parse(readFileSync(dumpPath, 'utf8'));
858
+ } catch (e) {
859
+ note({
860
+ severity: BLOCK,
861
+ area: 'route',
862
+ cause: `error view ${code} failed to compile`,
863
+ fix: 'check the view source compiles; ensure compile-view.php can write its cache output',
864
+ });
865
+ fail('compile-view failed for ' + code + ': ' + String(e.stderr || e.message || '').trim().slice(0, 800));
866
+ }
867
+ if (!dump || typeof dump.compiled !== 'string') fail('compile-view produced no compiled PHP for ' + viewName);
868
+
869
+ const viewModulePath = join(dist, 'src', 'views', 'errors.' + code + '.mjs');
870
+ execFileSync('node', [join(pkgDir, 'compiler', 'bin', 'transpile.mjs'), dumpPath, '--json', '--out', viewModulePath], { cwd: appDir });
871
+
872
+ const contextPath = join(dist, 'src', 'contexts', 'errors.' + code + '.mjs');
873
+ writeFileSync(contextPath, 'export default ' + JSON.stringify(dump.context) + ';\n');
874
+
875
+ const importName = 'errorView_' + String(code).replace(/[^A-Za-z0-9_]/g, '_');
876
+ errorViewImports.push(`import ${importName}Main from './views/errors.${code}.mjs';`);
877
+ errorViewImports.push(`import ${importName}Context from './contexts/errors.${code}.mjs';`);
878
+ errorViewCases.push({ code, importName, registryName });
879
+ console.log(` -> dist/src/views/errors.${code}.mjs`);
880
+ }
881
+ }
882
+
883
+ const errorViewModulePath = join(dist, 'src', 'error-views.generated.mjs');
884
+ const errorViewLines = ['// generated by build.mjs � do not edit', ...errorViewImports];
885
+ if (errorViewCases.length) {
886
+ errorViewLines.push("import { buildRt } from './runtime/rt-from-context.mjs';");
887
+ errorViewLines.push('');
888
+ // The layout registry the compiled error views resolve through
889
+ // (`$__env->make('errors::minimal')`): every non-status view compiled above.
890
+ errorViewLines.push('const errorViewRegistry = {};');
891
+ for (const { code, importName } of errorViewCases) {
892
+ if (/^[0-9]/.test(code)) continue;
893
+ errorViewLines.push(
894
+ `errorViewRegistry[${JSON.stringify('errors::' + code)}] = { main: ${importName}Main, context: ${importName}Context };`
895
+ );
896
+ }
897
+ errorViewLines.push('');
898
+ errorViewLines.push('export const errorViews = {');
899
+ for (const { code, importName, registryName } of errorViewCases) {
900
+ if (!/^[0-9]/.test(code)) continue;
901
+ errorViewLines.push(` ${JSON.stringify(code)}: (status, request, ctx, error) => {`);
902
+ // The exception the view sees: Laravel's framework views call
903
+ // `$exception->getMessage()` (403/500), and the compiled handler's error is
904
+ // a plain JS Error with `.status` - so it is wrapped in the exception
905
+ // surface those views use (Laravel's Throwable API).
906
+ errorViewLines.push(
907
+ ` const ex = error == null ? null : { message: String(error.message == null ? '' : error.message), status: Number(error.status) || Number(status) || 500,` +
908
+ ` getMessage() { return String(this.message); }, getCode() { return 0; }, getStatusCode() { return this.status; } };`
909
+ );
910
+ errorViewLines.push(
911
+ ` const rt = buildRt(${importName}Context, { strict: true, views: { ...errorViewRegistry }, data: { exception: ex, errors: {} } });`
912
+ );
913
+ errorViewLines.push(` ${importName}Main(rt);`);
914
+ // PhpEngine::evaluatePath() returns ltrim(ob_get_clean()): a rendered view
915
+ // never starts with whitespace (Laravel's own 404 body starts with
916
+ // "<!DOCTYPE", not a newline).
917
+ errorViewLines.push(' return String(rt.__output).replace(/^[ \\t\\n\\r\\0\\x0B]+/, "");');
918
+ errorViewLines.push(' },');
919
+ }
920
+ errorViewLines.push('};');
921
+ } else {
922
+ errorViewLines.push('export const errorViews = {};');
923
+ }
924
+ writeFileSync(errorViewModulePath, errorViewLines.join('\n') + '\n');
925
+
926
+ const errorRendererPath = join(dist, 'src', 'error-renderer.mjs');
927
+ writeFileSync(
928
+ errorRendererPath,
929
+ [
930
+ "// generated by build.mjs � do not edit",
931
+ "import { errorViews } from './error-views.generated.mjs';",
932
+ '',
933
+ 'export function renderErrorView(status, request, ctx, error) {',
934
+ ' const code = String(status);',
935
+ ' let key = code;',
936
+ ' if (!Object.prototype.hasOwnProperty.call(errorViews, key)) {',
937
+ ' key = String(Math.floor(Number(status) / 100)) + "xx";',
938
+ ' }',
939
+ ' const render = errorViews[key];',
940
+ ' if (typeof render !== "function") return null;',
941
+ ' try {',
942
+ ' return render(Number(status), request, ctx, error);',
943
+ ' } catch (e) {',
944
+ ' return null;',
945
+ ' }',
946
+ '}',
947
+ '',
948
+ ].join('\n')
949
+ );
950
+ console.log(` -> dist/src/error-renderer.mjs (${errorViewCases.length} view(s))`);
951
+
952
+ for (const [route, name] of portEntries) {
953
+ const slug = String(name);
954
+ const controllerPath = join(dist, 'src', 'controllers', slug + '.mjs');
955
+ writeFileSync(
956
+ controllerPath,
957
+ [
958
+ "export { default } from '../ports/" + name + ".mjs';",
959
+ '',
960
+ ].join('\n')
961
+ );
962
+ controllerByRoute.set(route, slug);
963
+ console.log(`port controller ${route} -> dist/src/controllers/${slug}.mjs`);
964
+ }
965
+
966
+ // ---------- 3c2. transpile controllers (single-method routes that still need a port) ----------
967
+ // A route only leaves the "needs a port" list when its controller method was fully
968
+ // transpiled at build time. The uri's main verb (GET, else POST) plus any extra
969
+ // verb (Breeze's GET+POST /login) are each compiled to their own "METHOD uri"
970
+ // handler / table entry. Anything outside the supported subset (auth flows,
971
+ // relations, unknown calls, non-scalar params, ...) stays blocked under --strict
972
+ // or keeps needing a port — the build never silently degrades.
973
+ const controllerRoutes = new Map(); // route -> { slug, fqcn, method }
974
+ // Does any compiled controller use the Storage facade? (decides the object store)
975
+ let usesStorage = false;
976
+ const verbControllers = []; // extra-verb handlers: { route, verb, slug, fqcn, method }
977
+ // The HTTP verbs the compiled rung can serve. OPTIONS is not one of them (the
978
+ // platform answers the CORS preflight, not the app).
979
+ const COMPILABLE_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
980
+ {
981
+ const methodsOk = (route) => {
982
+ const main = mainVerbFor(route);
983
+ // The compiled rung serves GET/POST/PUT/PATCH/DELETE; OPTIONS is not one
984
+ // of them (the platform answers the CORS preflight).
985
+ return COMPILABLE_METHODS.has(main);
986
+ };
987
+ const targets = [];
988
+ for (const { route } of needsPort) {
989
+ if (!methodsOk(route)) continue;
990
+ const action = actionForRoute(route);
991
+ if (!action || action === 'Closure') continue;
992
+ const mwList = middlewareForRoute(route) || [];
993
+ const blocking = mwList.filter((m) => !isHonouredMiddleware(m));
994
+ if (blocking.length) {
995
+ console.log(` skip controller ${route}: middleware [${blocking.join(', ')}] not transpiled — write a port`);
996
+ continue;
997
+ }
998
+ const at = action.lastIndexOf('@');
999
+ const fqcn = at >= 0 ? action.slice(0, at) : action;
1000
+ const method = at >= 0 ? action.slice(at + 1) : '__invoke';
1001
+ if (!/^[A-Za-z_][A-Za-z0-9_\\]*$/.test(fqcn) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(method)) continue;
1002
+ targets.push({ route, verb: mainVerbFor(route), fqcn, method });
1003
+ }
1004
+
1005
+ // Extra verbs on a uri whose main verb is already handled by a snapshot / view /
1006
+ // static page (Breeze's GET+POST /login is the canonical case). Each compiles to
1007
+ // its own handler keyed "METHOD uri"; a verb that cannot compile stays a partial
1008
+ // and still blocks the strict build.
1009
+ const extraTargets = [];
1010
+ // Verbs the compiled rung can serve as a controller. OPTIONS is not one of them
1011
+ // (the browser/CORS preflight is answered by the platform, not the app).
1012
+ for (const route of routes) {
1013
+ for (const verb of extraVerbsFor(route)) {
1014
+ const meta = metaForVerb(route, verb);
1015
+ const action = meta && meta.action;
1016
+ if (!COMPILABLE_METHODS.has(String(verb).toUpperCase())) {
1017
+ const alreadyTracked = partials.some((p) => p.route === route && p.methods.includes(verb));
1018
+ const ported = portEntries.some(([r]) => r === route);
1019
+ const ignored = ignoreMap.some(([r]) => r === route);
1020
+ if (!alreadyTracked && !ported && !ignored) {
1021
+ uncompiledExtraNonPost.push({ route, verb });
1022
+ }
1023
+ console.log(` skip ${verb} ${route}: only GET/POST/PUT/PATCH/DELETE handlers are compiled — write a port`);
1024
+ continue;
1025
+ }
1026
+ if (!action || action === 'Closure') continue;
1027
+ const mwList = (meta && meta.middleware) || [];
1028
+ const blocking = mwList.filter((m) => !isHonouredMiddleware(m));
1029
+ if (blocking.length) {
1030
+ console.log(` skip ${verb} ${route}: middleware [${blocking.join(', ')}] not transpiled — write a port`);
1031
+ continue;
1032
+ }
1033
+ const at = action.lastIndexOf('@');
1034
+ const fqcn = at >= 0 ? action.slice(0, at) : action;
1035
+ const method = at >= 0 ? action.slice(at + 1) : '__invoke';
1036
+ if (!/^[A-Za-z_][A-Za-z0-9_\\]*$/.test(fqcn) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(method)) continue;
1037
+ extraTargets.push({ route, verb, fqcn, method });
1038
+ }
1039
+ }
1040
+ for (const t of extraTargets) {
1041
+ if (!targets.some((x) => x.route === t.route && x.verb === t.verb)) targets.push(t);
1042
+ }
1043
+
1044
+ if (targets.length) {
1045
+ console.log(`• controller routes: trying to transpile ${targets.length} route(s)`);
1046
+
1047
+ const formatControllerError = (e) => {
1048
+ const raw = String(e.stderr || e.stdout || e.message || '').trim();
1049
+ try {
1050
+ const errs = JSON.parse(raw);
1051
+ if (Array.isArray(errs) && errs.length) {
1052
+ return errs
1053
+ .map((x) => `${x.code || 'LW'} ${x.message || ''}${x.line ? ` (line ${x.line})` : ''}`)
1054
+ .join('; ');
1055
+ }
1056
+ } catch {
1057
+ /* not a JSON error payload */
1058
+ }
1059
+ return raw.slice(0, 300) || 'unknown error';
1060
+ };
1061
+
1062
+ // resolve controller PHP files through the app's composer PSR-4 map
1063
+ let psr4 = {};
1064
+ try {
1065
+ const composer = JSON.parse(readFileSync(join(appDir, 'composer.json'), 'utf8'));
1066
+ psr4 = Object.fromEntries(
1067
+ Object.entries((composer.autoload && composer.autoload['psr-4']) || {}).map(([prefix, dirs]) => [
1068
+ prefix,
1069
+ Array.isArray(dirs) ? dirs : [dirs],
1070
+ ])
1071
+ );
1072
+ } catch {
1073
+ /* defaults below win */
1074
+ }
1075
+ const resolvePsr4 = (fqcn) => {
1076
+ let best = null;
1077
+ for (const [prefix, dirs] of Object.entries(psr4)) {
1078
+ if (fqcn.startsWith(prefix) && (!best || prefix.length > best[0].length)) best = [prefix, dirs];
1079
+ }
1080
+ if (!best) return null;
1081
+ const rest = fqcn.slice(best[0].length).replace(/\\/g, '/') + '.php';
1082
+ for (const d of best[1]) {
1083
+ const p = join(appDir, String(d).replace(/[\\/]+$/, ''), rest);
1084
+ if (existsSync(p)) return p;
1085
+ }
1086
+ return null;
1087
+ };
1088
+
1089
+ mkdirSync(join(dist, 'src', 'controllers'), { recursive: true });
1090
+ mkdirSync(join(dist, 'src', 'views'), { recursive: true });
1091
+ mkdirSync(join(dist, 'src', 'contexts'), { recursive: true });
1092
+ const metaDir = join(cacheDir, 'controllers');
1093
+ mkdirSync(metaDir, { recursive: true });
1094
+
1095
+ // phase A: extract method metadata (params, model classes, views)
1096
+ const prepared = [];
1097
+ for (const t of targets) {
1098
+ const file = resolvePsr4(t.fqcn);
1099
+ if (!file) {
1100
+ console.log(` controller ${t.route}: skipped — cannot resolve ${t.fqcn} via composer autoload`);
1101
+ continue;
1102
+ }
1103
+ try {
1104
+ const out = execFileSync(
1105
+ process.execPath,
1106
+ [join(pkgDir, 'compiler', 'bin', 'controller.mjs'), 'extract', '--file', file, '--method', t.method],
1107
+ { cwd: appDir }
1108
+ ).toString('utf8');
1109
+ prepared.push({ ...t, file, meta: JSON.parse(out) });
1110
+ } catch (e) {
1111
+ console.log(` controller ${t.route}: skipped — ${formatControllerError(e)}`);
1112
+ }
1113
+ }
1114
+
1115
+ if (prepared.length) {
1116
+ // phase B: model metadata (one PHP dump for every referenced class)
1117
+ const aliasToFqcns = new Map(); // alias -> Set(fqcn)
1118
+
1119
+ for (const p of prepared) {
1120
+ for (const c of p.meta.classes || []) {
1121
+ const set = aliasToFqcns.get(c.alias) || new Set();
1122
+ set.add(c.fqcn);
1123
+ aliasToFqcns.set(c.alias, set);
1124
+ }
1125
+ }
1126
+ // FormRequest candidates (a class-typed parameter that is not a plain
1127
+ // Request). The PHP dump decides whether it really is a FormRequest.
1128
+ const formRequestFqcns = new Set();
1129
+ for (const p of prepared) {
1130
+ for (const param of p.meta.params || []) {
1131
+ if (param.inject === 'request') continue;
1132
+ if (!param.typeFqcn) continue;
1133
+ formRequestFqcns.add(param.typeFqcn);
1134
+ }
1135
+ }
1136
+ // Class constants (e.g. RouteServiceProvider::HOME) are dumped too — the
1137
+ // value is baked into the handler.
1138
+ const constantFqcns = new Set();
1139
+ for (const p of prepared) {
1140
+ for (const c of p.meta.constants || []) constantFqcns.add(c.fqcn);
1141
+ }
1142
+ // The class-typed parameters: a FormRequest (injected like a Request) or a
1143
+ // model (Laravel's implicit route-model binding). The model dump decides
1144
+ // which one it is.
1145
+ const paramTypeFqcns = new Set();
1146
+ for (const p of prepared) {
1147
+ for (const param of p.meta.params || []) {
1148
+ if (param.inject === 'request') continue;
1149
+ if (!param.typeFqcn) continue;
1150
+ paramTypeFqcns.add(param.typeFqcn);
1151
+ }
1152
+ }
1153
+ const allFqcns = [...new Set([...aliasToFqcns.values()].flatMap((s) => [...s]))];
1154
+ // The model-bound parameters (Laravel's implicit route-model binding:
1155
+ // `show(Post $post)`): the type must be dumped too — the model and the key
1156
+ // it binds on.
1157
+ for (const fqcn of paramTypeFqcns) {
1158
+ if (!allFqcns.includes(fqcn)) allFqcns.push(fqcn);
1159
+ }
1160
+ // Eloquent auth provider models (config('auth.providers.*.model')) must be in the
1161
+ // generated registry even when no compiled controller references them.
1162
+ for (const fqcn of authProviderFqcns) {
1163
+ const short = fqcn.split('\\').pop();
1164
+ if (!aliasToFqcns.has(short)) aliasToFqcns.set(short, new Set());
1165
+ aliasToFqcns.get(short).add(fqcn);
1166
+ if (!allFqcns.includes(fqcn)) allFqcns.push(fqcn);
1167
+ }
1168
+ const modelDump = new Map();
1169
+ if (allFqcns.length) {
1170
+ console.log(`• dumping model metadata (${allFqcns.length}): ${allFqcns.join(', ')}`);
1171
+ try {
1172
+ const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'model-dump.php'), ...allFqcns], {
1173
+ cwd: appDir,
1174
+ env: childEnv(),
1175
+ }).toString('utf8');
1176
+ for (const d of JSON.parse(raw)) modelDump.set(d.fqcn, d);
1177
+ // A DECLARED relation names its related model (`$post->comments` is a
1178
+ // Comment), and the compiled read needs that class to exist at the
1179
+ // edge. The related models are not in any controller body, so they
1180
+ // are pulled in here - transitively, until nothing new arrives.
1181
+ const dumpMore = (fqcns) => {
1182
+ if (!fqcns.length) return;
1183
+ const more = execFileSync(PHP, [join(pkgDir, 'tools', 'model-dump.php'), ...fqcns], {
1184
+ cwd: appDir,
1185
+ env: childEnv(),
1186
+ }).toString('utf8');
1187
+ for (const d of JSON.parse(more)) modelDump.set(d.fqcn, d);
1188
+ };
1189
+ const seenRel = new Set();
1190
+ for (let round = 0; round < 4; round++) {
1191
+ const pending = [];
1192
+ for (const d of modelDump.values()) {
1193
+ for (const rel of Array.isArray(d.relations) ? d.relations : []) {
1194
+ if (seenRel.has(rel.related) || modelDump.has(rel.related)) continue;
1195
+ seenRel.add(rel.related);
1196
+ pending.push(rel.related);
1197
+ }
1198
+ }
1199
+ if (!pending.length) break;
1200
+ dumpMore(pending);
1201
+ }
1202
+ // The related models are compiled too (the emit loop walks
1203
+ // `aliasToFqcns`), and their short name is the alias.
1204
+ for (const fqcn of modelDump.keys()) {
1205
+ const short = fqcn.split('\\').pop();
1206
+ if (!aliasToFqcns.has(short)) aliasToFqcns.set(short, new Set());
1207
+ aliasToFqcns.get(short).add(fqcn);
1208
+ if (!allFqcns.includes(fqcn)) allFqcns.push(fqcn);
1209
+ }
1210
+ } catch (e) {
1211
+ console.log(
1212
+ ` model dump failed — controller routes stay blocked: ${String(e.stderr || e.message || '')
1213
+ .trim()
1214
+ .slice(0, 200)}`
1215
+ );
1216
+ }
1217
+ }
1218
+
1219
+ // The Gate/policy metadata: which policy answers for a model, which
1220
+ // abilities it has, whether a guest may call them, and whether the app
1221
+ // registers global Gate callbacks (before/after/define).
1222
+ let policyDump = { globals: { before: 0, after: 0, abilities: [] }, models: [] };
1223
+ if (allFqcns.length) {
1224
+ console.log(`• dumping the Gate policies (${allFqcns.length})`);
1225
+ try {
1226
+ const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'policy-dump.php'), ...allFqcns], {
1227
+ cwd: appDir,
1228
+ env: childEnv(),
1229
+ }).toString('utf8');
1230
+ policyDump = JSON.parse(raw);
1231
+ } catch (e) {
1232
+ console.log(
1233
+ ` policy dump failed — the routes that check the Gate stay blocked: ${String(e.stderr || e.message || '')
1234
+ .trim()
1235
+ .slice(0, 200)}`
1236
+ );
1237
+ }
1238
+ }
1239
+
1240
+ // The event listeners (the classes): at the edge there is no listener
1241
+ // runtime, so the build compiles the listeners (and the event classes).
1242
+ let classesDump = { events: {}, classes: {} };
1243
+ try {
1244
+ const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'event-dump.php'), appDir], {
1245
+ cwd: appDir,
1246
+ env: childEnv(),
1247
+ }).toString('utf8');
1248
+ classesDump = JSON.parse(raw);
1249
+ } catch (e) {
1250
+ console.log(
1251
+ ` event dump failed — the routes that dispatch an event stay blocked: ${String(e.stderr || e.message || '')
1252
+ .trim()
1253
+ .slice(0, 200)}`
1254
+ );
1255
+ }
1256
+
1257
+ // The GLOBAL Gate abilities' callback classes (`Gate::define('x',
1258
+ // 'App\\Policies\\Y@method')`) are app classes too: the kernel's gate
1259
+ // instantiates them, so they join the compiled set.
1260
+ const gateCallbackClasses = Object.values(
1261
+ (policyDump.globals && policyDump.globals.definitions) || {}
1262
+ )
1263
+ .filter((d) => d && d.kind === 'string' && d.callback)
1264
+ .map((d) => String(d.callback).split('@')[0]);
1265
+ if (gateCallbackClasses.length) {
1266
+ const seen = new Set(Object.keys(classesDump.classes || {}));
1267
+ for (const fqcn of gateCallbackClasses) {
1268
+ if (!seen.has(fqcn)) {
1269
+ try {
1270
+ const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'class-dump.php'), fqcn], {
1271
+ cwd: appDir,
1272
+ env: childEnv(),
1273
+ }).toString('utf8');
1274
+ const extra = JSON.parse(raw);
1275
+ for (const [k, v] of Object.entries(extra.classes || {})) classesDump.classes[k] = v;
1276
+ } catch (e) {
1277
+ console.log(
1278
+ ` the Gate callback class ${fqcn} was not dumped — its routes stay blocked: ${String(
1279
+ e.stderr || e.message || ''
1280
+ )
1281
+ .trim()
1282
+ .slice(0, 200)}`
1283
+ );
1284
+ }
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ // The models the policies mention (the user, the subject) need their
1290
+ // metadata too: a relation read inside a policy must fail loud.
1291
+ const policyModelFqcns = new Set();
1292
+ for (const pm of policyDump.models || []) {
1293
+ const paramLists = Object.values(pm.methods || {}).map((x) => x.params || []);
1294
+ if (pm.before) paramLists.push(pm.before.params || []);
1295
+ for (const params of paramLists) {
1296
+ for (const p of params) {
1297
+ if (p.type && !p.builtin && !policyModelFqcns.has(p.type)) policyModelFqcns.add(p.type);
1298
+ }
1299
+ }
1300
+ }
1301
+ const extraModelFqcns = [...policyModelFqcns].filter((fqcn) => !modelDump.has(fqcn));
1302
+ if (extraModelFqcns.length) {
1303
+ console.log(`• dumping the policy models (${extraModelFqcns.length}): ${extraModelFqcns.join(', ')}`);
1304
+ try {
1305
+ const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'model-dump.php'), ...extraModelFqcns], {
1306
+ cwd: appDir,
1307
+ env: childEnv(),
1308
+ }).toString('utf8');
1309
+ for (const d of JSON.parse(raw)) modelDump.set(d.fqcn, d);
1310
+ } catch (e) {
1311
+ console.log(
1312
+ ` policy model dump failed — the policies that use those models stay blocked: ${String(e.stderr || e.message || '')
1313
+ .trim()
1314
+ .slice(0, 200)}`
1315
+ );
1316
+ }
1317
+ }
1318
+ // The policy models join the generated registry (a policy may read them).
1319
+ for (const fqcn of policyModelFqcns) {
1320
+ const short = fqcn.split('\\').pop();
1321
+ if (!aliasToFqcns.has(short)) aliasToFqcns.set(short, new Set());
1322
+ aliasToFqcns.get(short).add(fqcn);
1323
+ }
1324
+
1325
+ // FormRequest rules (rules/messages/attributes) — dumped at build time.
1326
+ const formRequestDump = new Map();
1327
+ if (formRequestFqcns.size) {
1328
+ const list = [...formRequestFqcns];
1329
+ console.log(`• dumping FormRequest rules (${list.length}): ${list.join(', ')}`);
1330
+ try {
1331
+ const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'formrequest-dump.php'), ...list], {
1332
+ cwd: appDir,
1333
+ env: childEnv(),
1334
+ }).toString('utf8');
1335
+ for (const d of JSON.parse(raw)) formRequestDump.set(d.fqcn, d);
1336
+ } catch (e) {
1337
+ console.log(
1338
+ ` form request dump failed — controller routes stay blocked: ${String(e.stderr || e.message || '')
1339
+ .trim()
1340
+ .slice(0, 200)}`
1341
+ );
1342
+ }
1343
+ }
1344
+
1345
+ // A policy (and a compiled class) may read a class constant too
1346
+ // (`Post::PUBLISHED`): its model and policy classes join the dump, so
1347
+ // the body compiles instead of stopping on an unavailable constant.
1348
+ // This runs AFTER both dumps (they declare their own maps below the
1349
+ // controller loop, so reading them earlier is a TDZ error).
1350
+ for (const e of policyDump.models || []) {
1351
+ if (e && e.model) constantFqcns.add(e.model);
1352
+ if (e && e.policy) constantFqcns.add(e.policy);
1353
+ }
1354
+ for (const fqcn of Object.keys(classesDump.classes || {})) constantFqcns.add(fqcn);
1355
+
1356
+ // Class constants — dumped at build time and baked into the handler.
1357
+ const constantDump = new Map();
1358
+ if (constantFqcns.size) {
1359
+ const list = [...constantFqcns];
1360
+ console.log(`• dumping class constants (${list.length}): ${list.join(', ')}`);
1361
+ try {
1362
+ const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'constants-dump.php'), ...list], {
1363
+ cwd: appDir,
1364
+ env: childEnv(),
1365
+ }).toString('utf8');
1366
+ for (const d of JSON.parse(raw)) constantDump.set(d.fqcn, d);
1367
+ } catch (e) {
1368
+ console.log(
1369
+ ` constants dump failed — controller routes stay blocked: ${String(e.stderr || e.message || '')
1370
+ .trim()
1371
+ .slice(0, 200)}`
1372
+ );
1373
+ }
1374
+ }
1375
+
1376
+ // views used by prepared controllers (compiled once; skipped views block their routes)
1377
+ // A view can depend on other views (a layout: `@extends('layouts.app')`, a
1378
+ // partial: `@include('partials.x')`) — the compiled PHP names them, so
1379
+ // the build compiles the whole closure and the handler imports it.
1380
+ const neededViews = new Set();
1381
+ for (const p of prepared) for (const v of p.meta.views || []) neededViews.add(v);
1382
+ // The pagination view (the framework's `pagination::tailwind`): a route
1383
+ // with a paginator renders it (`$paginator->links()`).
1384
+ const paginationView = runtimeConfig['pagination.view'] || 'pagination::tailwind';
1385
+ const usesPagination = prepared.some((p) => p.meta.usesPaginator);
1386
+ if (usesPagination) neededViews.add(paginationView);
1387
+ // The kinds of the data the views receive (a view carries no type
1388
+ // information, so the build tells the compiler).
1389
+ const viewDataKinds = {};
1390
+ for (const p of prepared) {
1391
+ for (const [v, kinds] of Object.entries(p.meta.viewDataKinds || {})) {
1392
+ viewDataKinds[v] = { ...(viewDataKinds[v] || {}), ...kinds };
1393
+ }
1394
+ }
1395
+ if (usesPagination) {
1396
+ viewDataKinds[paginationView] = {
1397
+ ...(viewDataKinds[paginationView] || {}),
1398
+ paginator: 'paginator',
1399
+ elements: 'array',
1400
+ };
1401
+ }
1402
+ const compiledViews = new Set();
1403
+ const failedViews = new Set();
1404
+ const viewDeps = new Map();
1405
+ const scanViewDeps = (view, compiledPhp) => {
1406
+ const deps = [];
1407
+ const re = /make\(\s*'([^']+)'/g;
1408
+ let m;
1409
+ while ((m = re.exec(String(compiledPhp || '')))) {
1410
+ if (m[1] && !deps.includes(m[1])) deps.push(m[1]);
1411
+ }
1412
+ viewDeps.set(view, deps);
1413
+ return deps;
1414
+ };
1415
+ // The mailable's views (the dump carries them): a mailable's view is not
1416
+ // reached from a route, so the build compiles it here.
1417
+ const mailViews = Array.isArray(classesDump.mailViews) ? classesDump.mailViews : [];
1418
+ // The views PER mailable: only the route that sends a mailable needs
1419
+ // them (the flat list above is the compile queue).
1420
+ const mailViewsByClass =
1421
+ classesDump.mailViewsByClass && typeof classesDump.mailViewsByClass === 'object'
1422
+ ? classesDump.mailViewsByClass
1423
+ : {};
1424
+ const viewQueue = [...neededViews, ...mailViews];
1425
+ while (viewQueue.length) {
1426
+ const view = viewQueue.shift();
1427
+ if (compiledViews.has(view) || failedViews.has(view)) continue;
1428
+ const viewModulePath = join(dist, 'src', 'views', viewFileName(view) + '.mjs');
1429
+ const dumpPath = join(cacheDir, viewFileName(view) + '.compiled.json');
1430
+ if (existsSync(viewModulePath) && existsSync(dumpPath)) {
1431
+ // Already compiled (another route used it): read the dump for the
1432
+ // views it depends on.
1433
+ try {
1434
+ const dump = JSON.parse(readFileSync(dumpPath, 'utf8'));
1435
+ compiledViews.add(view);
1436
+ for (const d of scanViewDeps(view, dump.compiled)) {
1437
+ if (!compiledViews.has(d) && !failedViews.has(d)) viewQueue.push(d);
1438
+ }
1439
+ } catch {
1440
+ failedViews.add(view);
1441
+ }
1442
+ continue;
1443
+ }
1444
+ console.log(`compiling view ${view} (controller)`);
1445
+ try {
1446
+ execFileSync(PHP, [join(pkgDir, 'tools', 'compile-view.php'), view, dumpPath, '/'], {
1447
+ cwd: appDir,
1448
+ env: childEnv(),
1449
+ });
1450
+ const dump = JSON.parse(readFileSync(dumpPath, 'utf8'));
1451
+ if (!dump || typeof dump.compiled !== 'string') throw new Error('compile-view produced no compiled PHP');
1452
+ // The kinds of the view data (the pagination view receives a
1453
+ // paginator): the transpiler needs them to allow the method calls.
1454
+ if (viewDataKinds[view]) {
1455
+ dump.viewDataKinds = viewDataKinds[view];
1456
+ writeFileSync(dumpPath, JSON.stringify(dump));
1457
+ }
1458
+ execFileSync('node', [join(pkgDir, 'compiler', 'bin', 'transpile.mjs'), dumpPath, '--json', '--out', viewModulePath], {
1459
+ cwd: appDir,
1460
+ });
1461
+ writeFileSync(join(dist, 'src', 'contexts', viewFileName(view) + '.mjs'), 'export default ' + JSON.stringify(dump.context) + ';\n');
1462
+ compiledViews.add(view);
1463
+ for (const d of scanViewDeps(view, dump.compiled)) {
1464
+ if (!compiledViews.has(d) && !failedViews.has(d)) viewQueue.push(d);
1465
+ }
1466
+ } catch (e) {
1467
+ failedViews.add(view);
1468
+ console.log(` view ${view}: skipped — ${String(e.stderr || e.message || '').trim().slice(0, 300)}`);
1469
+ }
1470
+ }
1471
+ // The views a route needs: the ones it renders + the closure of their
1472
+ // dependencies (the layout).
1473
+ const viewsFor = (views) => {
1474
+ const out = [];
1475
+ const seen = new Set();
1476
+ const add = (v) => {
1477
+ if (seen.has(v)) return;
1478
+ seen.add(v);
1479
+ out.push(v);
1480
+ for (const d of viewDeps.get(v) || []) add(d);
1481
+ };
1482
+ for (const v of views || []) add(v);
1483
+ return out;
1484
+ };
1485
+
1486
+ // generated model classes + route-name -> uri map (imported by controller handlers)
1487
+ const classVarNames = new Map(); // fqcn -> generated class identifier
1488
+ const modelLines = ['// generated by laravel-workers — do not edit', "import { Model } from '../kernel/model.mjs';", ''];
1489
+ // The relation lines wait until every model class is DECLARED (a class
1490
+ // binding is not hoisted, so `static relations` may only name one after
1491
+ // the declaration ran).
1492
+ const relationLines = [];
1493
+ // The compiled accessors + the ones that did not compile (the build
1494
+ // reports them and the routes that read them stay blocked).
1495
+ const accessorLines = [];
1496
+ const accessorErrors = [];
1497
+ const accessorWork = [];
1498
+ // fqcn -> the accessor attribute names the edge now CARRIES (compiled):
1499
+ // they leave `notCompiled`, so a property read of one compiles.
1500
+ const compiledAccessors = new Map();
1501
+ // varName/fqcn/names: `.notCompiled` waits for the accessor pass.
1502
+ const notCompiledWork = [];
1503
+ for (const [, fqcns] of aliasToFqcns) {
1504
+ for (const fqcn of fqcns) {
1505
+ if (classVarNames.has(fqcn)) continue;
1506
+ const d = modelDump.get(fqcn);
1507
+ if (!d || !d.model) continue;
1508
+ const varName = 'M_' + fqcn.replace(/[^A-Za-z0-9]/g, '_');
1509
+ classVarNames.set(fqcn, varName);
1510
+ modelLines.push(`export class ${varName} extends Model {}`);
1511
+ modelLines.push(`${varName}.table = ${JSON.stringify(d.table)};`);
1512
+ modelLines.push(`${varName}.primaryKey = ${JSON.stringify(d.primaryKey || 'id')};`);
1513
+ const casts = d.casts || {};
1514
+ modelLines.push(`${varName}.fillable = ${JSON.stringify(d.fillable || [])};`);
1515
+ modelLines.push(`${varName}.casts = ${JSON.stringify(casts)};`);
1516
+ // `hashed` casts hash at set-time like Eloquent's
1517
+ // castAttributeAsHashedString(), using the app's configured bcrypt
1518
+ // rounds (Hash::make() parity). Only emitted when the cast is present.
1519
+ if (Object.values(casts).some((c) => String(c).toLowerCase() === 'hashed')) {
1520
+ const rounds = Number(runtimeConfig['hashing.bcrypt.rounds'] ?? 12);
1521
+ modelLines.push(`${varName}.hashRounds = ${Number.isFinite(rounds) ? rounds : 12};`);
1522
+ }
1523
+ modelLines.push(`${varName}.timestamps = ${d.timestamps === false ? 'false' : 'true'};`);
1524
+ modelLines.push(`${varName}.softDeletes = ${d.softDeletes ? 'true' : 'false'};`);
1525
+ modelLines.push(`${varName}.hidden = ${JSON.stringify(d.hidden || [])};`);
1526
+ modelLines.push(`${varName}.appends = ${JSON.stringify(d.appends || [])};`);
1527
+ // Emitted after the accessor pass: an accessor the edge COMPILES leaves the
1528
+ // list, so the kernel does not refuse the read at request time.
1529
+ notCompiledWork.push([varName, fqcn, d.notCompiled || []]);
1530
+ // The model's ACCESSORS (`getFullNameAttribute`): compiled in the
1531
+ // second pass, once every model class is declared.
1532
+ for (const acc of Array.isArray(d.accessors) ? d.accessors : []) {
1533
+ accessorWork.push([varName, fqcn, acc]);
1534
+ }
1535
+ // The declared relations (dumped from the model's own return types):
1536
+ // `static relations = { comments: { type, model, foreignKey, ... } }`
1537
+ // - the kernel loads one lazily when it is read as a property.
1538
+ for (const rel of Array.isArray(d.relations) ? d.relations : []) {
1539
+ relationLines.push([varName, rel]);
1540
+ }
1541
+ modelLines.push('');
1542
+ }
1543
+ }
1544
+ // The relations, now that every model class exists. A relation whose
1545
+ // related model is not compiled is dropped: the read must fail the build
1546
+ // (it stays in `notCompiled`), never answer a half-loaded relation.
1547
+ for (const [varName, rel] of relationLines) {
1548
+ const relatedVar = classVarNames.get(rel.related);
1549
+ if (!relatedVar) continue;
1550
+ modelLines.push(
1551
+ `${varName}.relations = Object.assign({}, ${varName}.relations, { ${JSON.stringify(
1552
+ rel.name
1553
+ )}: { type: ${JSON.stringify(rel.type)}, model: ${relatedVar}, foreignKey: ${JSON.stringify(
1554
+ rel.foreignKey
1555
+ )}${rel.type === 'belongsTo' ? `, ownerKey: ${JSON.stringify(rel.ownerKey)}` : `, localKey: ${JSON.stringify(rel.localKey)}`} } });`
1556
+ );
1557
+ }
1558
+ // The model surface the accessor bodies compile against (the same maps
1559
+ // the controller path uses - built here because the model file is written
1560
+ // before those are declared).
1561
+ const accModelFqcns = new Set();
1562
+ const accNotCompiled = new Map();
1563
+ const accRelations = new Map();
1564
+ const accClassFqcns = {};
1565
+ for (const [alias, fqcns] of aliasToFqcns) {
1566
+ for (const fqcn of fqcns) {
1567
+ const d = modelDump.get(fqcn);
1568
+ if (!d || !d.model) continue;
1569
+ accModelFqcns.add(fqcn);
1570
+ accClassFqcns[alias] = fqcn;
1571
+ accClassFqcns[fqcn] = fqcn;
1572
+ if (Array.isArray(d.notCompiled) && d.notCompiled.length) {
1573
+ accNotCompiled.set(alias, d.notCompiled);
1574
+ accNotCompiled.set(fqcn, d.notCompiled);
1575
+ }
1576
+ if (Array.isArray(d.relations) && d.relations.length) {
1577
+ const byName = {};
1578
+ for (const rel of d.relations) {
1579
+ byName[rel.name] = { type: rel.type, related: rel.related, foreignKey: rel.foreignKey };
1580
+ }
1581
+ accRelations.set(alias, byName);
1582
+ accRelations.set(fqcn, byName);
1583
+ }
1584
+ }
1585
+ }
1586
+ for (const [varName, fqcn, acc] of accessorWork) {
1587
+ const compiledAcc = compileOneMethod(fqcn, acc.file, acc.method, {
1588
+ models: accModelFqcns,
1589
+ modelNotCompiled: accNotCompiled,
1590
+ modelClassFqcns: accClassFqcns,
1591
+ modelRelations: accRelations,
1592
+ // The class constants are dumped later in the build, so an accessor
1593
+ // that reads one fails loud (a precise reason) instead of guessing.
1594
+ constants: {},
1595
+ classes: new Set(Object.keys(classesDump.classes || {})),
1596
+ });
1597
+ if (compiledAcc.error) {
1598
+ accessorErrors.push(`${fqcn}::${acc.method}() — ${compiledAcc.error}`);
1599
+ continue;
1600
+ }
1601
+ modelLines.push(`${varName}.prototype[${JSON.stringify(acc.method)}] = ${compiledAcc.js};`);
1602
+ const names = compiledAccessors.get(fqcn) || [];
1603
+ names.push(acc.name);
1604
+ compiledAccessors.set(fqcn, names);
1605
+ }
1606
+ for (const e of accessorErrors) console.log(` ! accessor ${e}`);
1607
+ for (const [varName, fqcn, names] of notCompiledWork) {
1608
+ const stillRefused = names.filter((n) => !(compiledAccessors.get(fqcn) || []).includes(n));
1609
+ modelLines.push(`${varName}.notCompiled = ${JSON.stringify(stillRefused)};`);
1610
+ }
1611
+ modelLines.push('');
1612
+ const mapPairs = [];
1613
+ for (const [alias, fqcns] of aliasToFqcns) {
1614
+ if (fqcns.size !== 1) continue; // ambiguous alias: never guessed
1615
+ const fqcn = [...fqcns][0];
1616
+ if (!classVarNames.has(fqcn)) continue;
1617
+ mapPairs.push(`${JSON.stringify(alias)}: ${classVarNames.get(fqcn)}`);
1618
+ mapPairs.push(`${JSON.stringify(fqcn)}: ${classVarNames.get(fqcn)}`);
1619
+ }
1620
+ modelLines.push('export const models = { ' + mapPairs.join(', ') + ' };');
1621
+ const routeMapObj = {};
1622
+ for (const route of routes) {
1623
+ const { name } = metaForRoute(route);
1624
+ if (name && !(name in routeMapObj)) routeMapObj[name] = route.replace(/^\//, '');
1625
+ }
1626
+ modelLines.push('export const routeMap = ' + JSON.stringify(routeMapObj) + ';');
1627
+ modelLines.push('');
1628
+ writeFileSync(join(dist, 'src', 'models.generated.mjs'), modelLines.join('\n'));
1629
+
1630
+ // The compiled policies (the Gate checks). The policies become plain
1631
+ // async functions; the guest rules and the before() hook are baked in.
1632
+ const knownModelFqcnSet = new Set();
1633
+ const modelNotCompiled = new Map();
1634
+ const modelClassFqcns = {};
1635
+ for (const [alias, fqcns] of aliasToFqcns) {
1636
+ for (const fqcn of fqcns) {
1637
+ const d = modelDump.get(fqcn);
1638
+ if (!d || !d.model) continue;
1639
+ knownModelFqcnSet.add(fqcn);
1640
+ modelClassFqcns[alias] = fqcn;
1641
+ modelClassFqcns[fqcn] = fqcn;
1642
+ if (Array.isArray(d.notCompiled) && d.notCompiled.length) {
1643
+ modelNotCompiled.set(alias, d.notCompiled);
1644
+ modelNotCompiled.set(fqcn, d.notCompiled);
1645
+ }
1646
+ }
1647
+ }
1648
+ // The dumped constants keyed by the name the source writes (the alias
1649
+ // and the FQCN). An alias shared by two classes is skipped: the
1650
+ // transpiler must not guess which one the source meant.
1651
+ const constantsByAlias = {};
1652
+ const aliasOwners = new Map();
1653
+ for (const [fqcn, dumped] of constantDump) {
1654
+ if (!dumped || dumped.error) continue;
1655
+ const alias = fqcn.split('\\').pop();
1656
+ if (aliasOwners.has(alias) && aliasOwners.get(alias) !== fqcn) {
1657
+ aliasOwners.set(alias, null);
1658
+ } else {
1659
+ aliasOwners.set(alias, fqcn);
1660
+ }
1661
+ }
1662
+ for (const [fqcn, dumped] of constantDump) {
1663
+ if (!dumped || dumped.error) continue;
1664
+ const alias = fqcn.split('\\').pop();
1665
+ if (aliasOwners.get(alias) === fqcn) constantsByAlias[alias] = dumped.constants || {};
1666
+ constantsByAlias[fqcn] = dumped.constants || {};
1667
+ }
1668
+ const policyCompile = compilePolicies(policyDump, {
1669
+ models: knownModelFqcnSet,
1670
+ modelNotCompiled,
1671
+ modelClassFqcns,
1672
+ // The user of a policy: the app's auth provider model.
1673
+ userModel: authProviderFqcns[0] || null,
1674
+ // The class constants the policy bodies may read.
1675
+ constants: constantsByAlias,
1676
+ });
1677
+ writeFileSync(join(dist, 'src', 'policies.generated.mjs'), policyCompile.code);
1678
+ const policiesMeta = policyCompile.meta;
1679
+ console.log(
1680
+ ` -> dist/src/policies.generated.mjs (${Object.keys(policiesMeta).length} model(s)${
1681
+ policyCompile.errors.length ? `, ${policyCompile.errors.length} policy/policies did not compile` : ''
1682
+ })`
1683
+ );
1684
+ for (const e of policyCompile.errors) {
1685
+ console.log(` ! ${e.model}: ${e.message}`);
1686
+ }
1687
+
1688
+ // The classes (the event listeners + the event classes): the compiled
1689
+ // constructors and methods (the edge has no listener runtime).
1690
+ const classesCompile = compileClasses(classesDump, {
1691
+ models: knownModelFqcnSet,
1692
+ modelNotCompiled,
1693
+ modelClassFqcns,
1694
+ // The class constants a listener/service body may read.
1695
+ constants: constantsByAlias,
1696
+ classes: new Set(Object.keys(classesDump.classes || {})),
1697
+ });
1698
+ writeFileSync(join(dist, 'src', 'classes.generated.mjs'), classesCompile.code);
1699
+ // The alias of each compiled class (`new PostPublished($id)`): an
1700
+ // ambiguous short name is not resolved (the build fails loud on it).
1701
+ const classAliases = new Map();
1702
+ for (const fqcn of Object.keys(classesCompile.meta)) {
1703
+ const alias = fqcn.split('\\').pop();
1704
+ if (classAliases.has(alias)) classAliases.delete(alias);
1705
+ else classAliases.set(alias, fqcn);
1706
+ }
1707
+ // The events that did not compile (the event class itself or one of its
1708
+ // listeners): a dispatch of one of them would be a silent no-op (the edge
1709
+ // has no listener runtime), so the routes that dispatch it stay blocked -
1710
+ // and the build names the reason (the listener is queued, the listener
1711
+ // injects a service, ...).
1712
+ const classErrors = new Map();
1713
+ for (const e of classesCompile.errors) classErrors.set(e.class, e.message);
1714
+ const blockedClasses = {};
1715
+ const blockedShortNames = new Set();
1716
+ // EVERY class that did not compile is blocked - not only the event
1717
+ // classes: a mailable with Laravel 13's modern API, a listener with a
1718
+ // queued trait, ... The transpiler then refuses to instantiate it, and
1719
+ // the route that instantiates it is stopped with the reason.
1720
+ for (const [cls, why] of classErrors) {
1721
+ blockedClasses[cls] = why;
1722
+ const short = cls.split('\\').pop();
1723
+ if (!blockedShortNames.has(short)) {
1724
+ blockedShortNames.add(short);
1725
+ blockedClasses[short] = why;
1726
+ }
1727
+ }
1728
+ for (const [event, info] of Object.entries(classesDump.events || {})) {
1729
+ const names = [event, ...(info.listeners || [])];
1730
+ const bad = names.find((n) => classErrors.has(n));
1731
+ if (!bad) continue;
1732
+ const why = classErrors.get(bad);
1733
+ blockedClasses[event] = why;
1734
+ const short = event.split('\\').pop();
1735
+ if (!blockedShortNames.has(short)) {
1736
+ blockedShortNames.add(short);
1737
+ blockedClasses[short] = why;
1738
+ }
1739
+ }
1740
+ const classesMeta = {
1741
+ fqcns: Object.keys(classesCompile.meta),
1742
+ // The JOB classes (app/Jobs): `Job::dispatch()` writes a queue row and
1743
+ // the cron consumer runs the class's handle(), so the transpiler must
1744
+ // know which classes are jobs (a plain `dispatch()` on anything else
1745
+ // is not a queue push).
1746
+ jobs: Object.keys(classesDump.classes || {}).filter(
1747
+ (fqcn) => (classesDump.classes[fqcn] || {}).kind === 'job'
1748
+ ),
1749
+ aliases: Object.fromEntries(classAliases),
1750
+ blocked: blockedClasses,
1751
+ // The compiled classes' own methods (FQCN -> { methods, base }): a
1752
+ // call on an INLINE instance (`(new OrderService())->doIt($o)`) must
1753
+ // hand the runtime over as the first argument - the compiled methods
1754
+ // are `async function (rt, ...)`. Without this the app's first
1755
+ // argument landed in the `rt` slot (a 500 where Laravel answers 200).
1756
+ registry: Object.fromEntries(
1757
+ Object.entries(classesCompile.meta).map(([fqcn, m]) => [
1758
+ fqcn,
1759
+ { methods: m.methods || [], base: m.base || null },
1760
+ ])
1761
+ ),
1762
+ };
1763
+ console.log(
1764
+ ` -> dist/src/classes.generated.mjs (${classesMeta.fqcns.length} class(es)${
1765
+ classesCompile.errors.length ? `, ${classesCompile.errors.length} did not compile` : ''
1766
+ })`
1767
+ );
1768
+ for (const e of classesCompile.errors) {
1769
+ console.log(` ! ${e.code || 'LW002'} ${e.class}: ${e.message}`);
1770
+ }
1771
+
1772
+ // phase C: emit one handler module per transpilable route
1773
+ let emittedCount = 0;
1774
+ for (const p of prepared) {
1775
+ // A class the route instantiates that did not compile (a mailable with
1776
+ // Laravel 13's modern API, ...) makes the route fail at REQUEST time -
1777
+ // the build stops the route and names the reason.
1778
+ const brokenClass = (p.meta.newClasses || []).find(
1779
+ (c) => classErrors.has(c.fqcn) || classErrors.has(c.alias)
1780
+ );
1781
+ if (brokenClass) {
1782
+ console.log(
1783
+ ` controller ${p.route}: skipped — the class ${brokenClass.fqcn} did not compile: ${
1784
+ classErrors.get(brokenClass.fqcn) || classErrors.get(brokenClass.alias)
1785
+ }`
1786
+ );
1787
+ continue;
1788
+ }
1789
+ const ambiguous = (p.meta.classes || []).find((c) => (aliasToFqcns.get(c.alias) || new Set()).size > 1);
1790
+ if (ambiguous) {
1791
+ console.log(` controller ${p.route}: skipped — model alias "${ambiguous.alias}" is ambiguous between namespaces`);
1792
+ continue;
1793
+ }
1794
+ // A JOB class is not a model: `Job::dispatch()` is a queue push the
1795
+ // transpiler compiles, so it must not be judged by the model dump.
1796
+ const jobFqcns = new Set(
1797
+ Object.keys(classesDump.classes || {}).filter(
1798
+ (fqcn) => (classesDump.classes[fqcn] || {}).kind === 'job'
1799
+ )
1800
+ );
1801
+ // A framework class the transpiler compiles ITSELF (`Collection::make()`
1802
+ // / `Collection::wrap()` -> the kernel Collection) is not an app model,
1803
+ // so the model dump must not judge it. Without this the route is
1804
+ // skipped with "not a usable Eloquent model" - a misleading refusal for
1805
+ // a construct that compiles.
1806
+ const frameworkFqcns = new Set(['Collection', 'Illuminate\\Support\\Collection']);
1807
+ const badModel = (p.meta.classes || []).find((c) => {
1808
+ if (jobFqcns.has(c.fqcn)) return false;
1809
+ if (frameworkFqcns.has(c.fqcn)) return false;
1810
+ const d = modelDump.get(c.fqcn);
1811
+ return !d || !d.model;
1812
+ });
1813
+ if (badModel) {
1814
+ const d = modelDump.get(badModel.fqcn);
1815
+ console.log(
1816
+ ` controller ${p.route}: skipped — ${badModel.fqcn} is not a usable Eloquent model${
1817
+ d && d.error ? ' (' + d.error + ')' : ''
1818
+ }`
1819
+ );
1820
+ continue;
1821
+ }
1822
+ // FormRequest rules for this route (by FQCN). A class-typed parameter
1823
+ // that is not a FormRequest is a service — the route needs a port.
1824
+ const formRequests = {};
1825
+ let formRequestError = null;
1826
+ for (const param of p.meta.params || []) {
1827
+ if (param.inject === 'request') continue;
1828
+ if (!param.typeFqcn) continue;
1829
+ // A model-typed parameter is the implicit route-model binding (the
1830
+ // model comes from the route segment) — it is not a FormRequest.
1831
+ const md = modelDump.get(param.typeFqcn);
1832
+ if (md && md.model) continue;
1833
+ const dumped = formRequestDump.get(param.typeFqcn);
1834
+ if (!dumped || dumped.error) {
1835
+ // The dump knows WHY (a rule object, an overridden
1836
+ // prepareForValidation(), ...): the reason must survive - a generic
1837
+ // "unsupported type" would hide it.
1838
+ formRequestError =
1839
+ dumped && dumped.error
1840
+ ? `$${param.name} (${param.typeHint}): ${dumped.error}`
1841
+ : `$${param.name} has unsupported type "${param.typeHint}" — only scalar parameters, Illuminate\\Http\\Request and FormRequest are supported here`;
1842
+ break;
1843
+ }
1844
+ formRequests[param.typeFqcn] = {
1845
+ rules: dumped.rules || {},
1846
+ messages: dumped.messages || {},
1847
+ attributes: dumped.attributes || {},
1848
+ // The hooks Laravel runs on the request (prepareForValidation) -
1849
+ // the compiled handler runs them in Laravel's order.
1850
+ hooks: dumped.hooks || [],
1851
+ };
1852
+ }
1853
+ if (formRequestError) {
1854
+ console.log(` controller ${p.route}: skipped — ${formRequestError}`);
1855
+ continue;
1856
+ }
1857
+ // Class constants for this route (by alias — the transpiler resolves the
1858
+ // alias as written in the source).
1859
+ const constants = {};
1860
+ let constantError = null;
1861
+ for (const c of p.meta.constants || []) {
1862
+ const dumped = constantDump.get(c.fqcn);
1863
+ if (!dumped || dumped.error) {
1864
+ constantError = `${c.fqcn} (${dumped ? dumped.error : 'not dumped'})`;
1865
+ break;
1866
+ }
1867
+ if (constants[c.alias]) {
1868
+ constantError = `${c.alias} is ambiguous between classes`;
1869
+ break;
1870
+ }
1871
+ constants[c.alias] = dumped.constants || {};
1872
+ }
1873
+ if (constantError) {
1874
+ console.log(` controller ${p.route}: skipped — class constant ${constantError}`);
1875
+ continue;
1876
+ }
1877
+ // The views the route renders + the ones they depend on (the layout).
1878
+ // The route's views + the pagination view (when the route has a
1879
+ // paginator: `$paginator->links()` renders it).
1880
+ const routeViewNames = [...(p.meta.views || [])];
1881
+ // A mailable's view (the route sends a mail): the runtime renders it
1882
+ // through the ROUTE's runtime, so the view must be in THIS route's
1883
+ // view map. The mailables a route can send are the ones it REACHES:
1884
+ // the classes its body instantiates, the listeners of the events it
1885
+ // dispatches, and their dependencies - a route that dispatches an event
1886
+ // whose listener sends a mailable needs that mailable's view.
1887
+ // The views are attached per route: a mailable no route reaches costs
1888
+ // no route a view (and one broken mailable view no longer skips every
1889
+ // route of the app).
1890
+ const routeReach = reachableClasses(p.meta.newClasses, classesMeta, {
1891
+ classMeta: classesCompile.meta,
1892
+ dumpClasses: classesDump.classes,
1893
+ events: classesDump.events,
1894
+ });
1895
+ for (const mailable of reachedMailables(routeReach.reached, classesDump.classes)) {
1896
+ for (const v of mailViewsByClass[mailable] || []) routeViewNames.push(v);
1897
+ }
1898
+ if (p.meta.usesPaginator) routeViewNames.push(paginationView);
1899
+ const routeViews = viewsFor(routeViewNames);
1900
+ const missingView = routeViews.find((v) => !compiledViews.has(v));
1901
+ if (missingView) {
1902
+ // A mailable's view that did not compile: the reason names the
1903
+ // mailable (the class that renders it) and the route that reaches it -
1904
+ // the route would answer 500 where Laravel sends the mail.
1905
+ const mailableOf = Object.keys(mailViewsByClass).find(
1906
+ (m) => (mailViewsByClass[m] || []).includes(missingView)
1907
+ );
1908
+ console.log(
1909
+ mailableOf
1910
+ ? ` controller ${p.route}: skipped — the view "${missingView}" of the mailable ${mailableOf} did not compile`
1911
+ : ` controller ${p.route}: skipped — view "${missingView}" did not compile`
1912
+ );
1913
+ continue;
1914
+ }
1915
+ // The CORS paths (HandleCors): the compiled worker does not add the CORS
1916
+ // headers, so a route under a CORS path is skipped (a silent answer
1917
+ // without the headers would break the browser's CORS).
1918
+ const corsPath = corsPathFor(p.route);
1919
+ if (corsPath) {
1920
+ console.log(
1921
+ ` controller ${p.route}: skipped — the app configures CORS for "${corsPath}" and the ` +
1922
+ 'compiled worker does not add the CORS headers — remove the CORS config (config/cors.php) ' +
1923
+ 'or answer the preflight on the platform'
1924
+ );
1925
+ continue;
1926
+ }
1927
+ const isExtraVerb = extraVerbsFor(p.route).includes(p.verb);
1928
+ // `--` keeps the extra-verb slug apart from a base route slug
1929
+ // (POST /posts -> "posts--post" vs GET /posts/{post} -> "posts-post").
1930
+ const slug = isExtraVerb
1931
+ ? `${slugifyRoute(p.route)}--${String(p.verb).toLowerCase()}`
1932
+ : slugifyRoute(p.route);
1933
+ const meta = {
1934
+ routeUri: p.route,
1935
+ // The pagination view (the framework's `pagination::tailwind`): the
1936
+ // handler imports it when the route has a paginator.
1937
+ paginationView,
1938
+ // A paginator in the method + the kinds of the data the views get.
1939
+ usesPaginator: !!p.meta.usesPaginator,
1940
+ viewDataKinds: p.meta.viewDataKinds || {},
1941
+ routeParams: (p.route.match(/\{([^}]+)\}/g) || []).map((s) => s.slice(1, -1).replace(/[?*]$/, '')),
1942
+ models: (() => {
1943
+ // The models this route uses: the classes in the body + the
1944
+ // model-bound parameters (the implicit route-model binding).
1945
+ const list = [];
1946
+ const seen = new Set();
1947
+ const push = (alias, fqcn) => {
1948
+ if (!fqcn || seen.has(fqcn)) return;
1949
+ seen.add(fqcn);
1950
+ const d = modelDump.get(fqcn) || {};
1951
+ list.push({
1952
+ alias,
1953
+ fqcn,
1954
+ // The methods the app declares on the model (the relations, the
1955
+ // accessors): the compiler rejects them (a relation read as a
1956
+ // property used to answer null - a wrong answer, silently).
1957
+ notCompiled: (d.notCompiled || []).filter(
1958
+ (n) => !(compiledAccessors.get(fqcn) || []).includes(n)
1959
+ ),
1960
+ // The relations the model declares: a property read of one is
1961
+ // COMPILED (a lazy load), not refused. A relation whose model
1962
+ // is not compiled is dropped here, so it stays refused.
1963
+ relations: (Array.isArray(d.relations) ? d.relations : []).filter((r) =>
1964
+ modelDump.has(r.related) && modelDump.get(r.related).model
1965
+ ),
1966
+ // The route-model binding key ('id' by default) + the key.
1967
+ routeKey: d.routeKey || d.primaryKey || 'id',
1968
+ primaryKey: d.primaryKey || 'id',
1969
+ });
1970
+ };
1971
+ for (const c of p.meta.classes || []) {
1972
+ const d = modelDump.get(c.fqcn);
1973
+ if (!d || !d.model) continue;
1974
+ push(c.alias, c.fqcn);
1975
+ }
1976
+ for (const param of p.meta.params || []) {
1977
+ if (param.inject === 'request') continue;
1978
+ if (!param.typeFqcn) continue;
1979
+ const d = modelDump.get(param.typeFqcn);
1980
+ if (!d || !d.model) continue;
1981
+ push(param.typeFqcn.split('\\').pop(), param.typeFqcn);
1982
+ }
1983
+ return list;
1984
+ })(),
1985
+ views: routeViews,
1986
+ constants,
1987
+ formRequests,
1988
+ // The Gate metadata (the policies, their abilities, the guest rules).
1989
+ policies: policiesMeta,
1990
+ // The GLOBAL Gate::define() abilities: the compilable ones (a
1991
+ // 'Class@method' string callback) reach the kernel's gate; a closure
1992
+ // never gets here (the transpiler refuses the call).
1993
+ gateDefinitions: Object.fromEntries(
1994
+ Object.entries((policyDump.globals || {}).definitions || {}).filter(
1995
+ ([, d]) => d && d.kind === 'string' && d.callback
1996
+ )
1997
+ ),
1998
+ classes: classesMeta,
1999
+ // The object store: the app's default disk (the compiled rung has ONE
2000
+ // store — the padanan for it) + the configured disk names.
2001
+ storage: {
2002
+ defaultDisk: runtimeConfig['filesystems.default'] || null,
2003
+ disks: Array.isArray(runtimeConfig['filesystems.disks'])
2004
+ ? runtimeConfig['filesystems.disks']
2005
+ : [],
2006
+ },
2007
+ // Hash::make() must use the app's configured bcrypt rounds.
2008
+ hashRounds: Number(runtimeConfig['hashing.bcrypt.rounds'] ?? 12) || 12,
2009
+ };
2010
+ const metaPath = join(metaDir, slug + '.meta.json');
2011
+ writeFileSync(metaPath, JSON.stringify(meta, null, 2));
2012
+ const outPath = join(dist, 'src', 'controllers', slug + '.mjs');
2013
+ try {
2014
+ execFileSync(
2015
+ process.execPath,
2016
+ [
2017
+ join(pkgDir, 'compiler', 'bin', 'controller.mjs'),
2018
+ 'emit',
2019
+ '--file', p.file,
2020
+ '--method', p.method,
2021
+ '--meta', metaPath,
2022
+ '--out', outPath,
2023
+ ],
2024
+ { cwd: appDir }
2025
+ );
2026
+ // A handler that queues a job writes a `lw_jobs` row the CRON
2027
+ // TRIGGER drains. With no trigger the job would never run, so the
2028
+ // build refuses it below (the route itself is fine - the deployment
2029
+ // is not).
2030
+ if (readFileSync(outPath, 'utf8').includes('rt.queuePush(')) {
2031
+ queuePushes.push(p.route);
2032
+ }
2033
+ if (isExtraVerb) {
2034
+ verbControllers.push({ route: p.route, verb: p.verb, slug, fqcn: p.fqcn, method: p.method });
2035
+ console.log(` controller ${p.verb} ${p.route} -> dist/src/controllers/${slug}.mjs (${p.fqcn}@${p.method})`);
2036
+ } else {
2037
+ controllerRoutes.set(p.route, { slug, fqcn: p.fqcn, method: p.method });
2038
+ console.log(` controller ${p.route} -> dist/src/controllers/${slug}.mjs (${p.fqcn}@${p.method})`);
2039
+ }
2040
+ emittedCount++;
2041
+ } catch (e) {
2042
+ const detail = formatControllerError(e);
2043
+ console.log(` controller ${p.route}: skipped — ${detail}`);
2044
+ // The upload: the operator needs to know that the edge has no
2045
+ // multipart parser and what to do about it (an object store — R2
2046
+ // asks for a credit card; the free path is the D1 blob store — or
2047
+ // remove/disable the upload).
2048
+ if (/an uploaded file/.test(detail)) {
2049
+ console.log(
2050
+ ' ! this route uploads a file: the edge has no multipart parser. Bind R2\n' +
2051
+ ' ("r2_buckets": [{"binding":"R2","bucket_name":"<bucket>"}] in wrangler.jsonc - R2 asks for a\n' +
2052
+ ' credit card; the free path is the D1 blob store, 1.5 MB per object) or remove/disable\n' +
2053
+ ' the upload in this route.'
2054
+ );
2055
+ }
2056
+ }
2057
+ }
2058
+ if (emittedCount) {
2059
+ console.log(`• controllers transpiled: ${emittedCount}/${targets.length}`);
2060
+ for (let i = needsPort.length - 1; i >= 0; i--) {
2061
+ if (controllerRoutes.has(needsPort[i].route)) needsPort.splice(i, 1);
2062
+ }
2063
+ }
2064
+
2065
+ // A route a COMPILED controller serves is NOT a snapshot: the build-time
2066
+ // page is the fallback for a route the build could not compile. Keeping
2067
+ // both hid the controller (the shadow compared a page baked at build
2068
+ // time and called it parity - a stale answer for anything that depends
2069
+ // on the request: a query string, a row created during the run).
2070
+ for (const route of [...snapshots]) {
2071
+ if (!controllerRoutes.has(route)) continue;
2072
+ snapshots.splice(snapshots.indexOf(route), 1);
2073
+ const file = route === '/' ? 'index.html' : route.slice(1) + '/index.html';
2074
+ try {
2075
+ rmSync(join(dist, 'public', file), { force: true });
2076
+ } catch {
2077
+ /* the page was never written */
2078
+ }
2079
+ console.log(` snapshot ${route} dropped - the compiled controller serves it`);
2080
+ }
2081
+
2082
+ // The Storage facade needs an object store: the app's local disk is
2083
+ // object storage at the edge (there is no local filesystem). R2 when the
2084
+ // app binds it, else the D1 blob store (one row per object - no extra
2085
+ // Cloudflare product, no card). With neither, the compiled handler would
2086
+ // only fail at request time - catch it here, where the build can still
2087
+ // tell the operator what to add.
2088
+ if (emittedCount) {
2089
+ const controllerDir = join(dist, 'src', 'controllers');
2090
+ const controllerSources = readdirSync(controllerDir)
2091
+ .filter((f) => f.endsWith('.mjs'))
2092
+ .map((f) => readFileSync(join(controllerDir, f), 'utf8'));
2093
+ usesStorage = controllerSources.some((s) => s.includes('rt.storage()'));
2094
+ if (usesStorage) {
2095
+ if (hasBinding(appDir, 'r2_buckets', 'R2')) {
2096
+ console.log('• Storage:: -> R2 (the R2 binding)');
2097
+ } else if (hasBinding(appDir, 'd1_databases', 'DB')) {
2098
+ console.log('• Storage:: -> the D1 blob store (no R2 binding): one row per object in lw_blobs.');
2099
+ console.log(' Free-plan limits apply: 100k row writes/day, 5M row reads/day, 500 MB per database, 1.5 MB per object.');
2100
+ console.log(' Bind an R2 bucket ("r2_buckets": [{"binding":"R2",...}]) for real object storage (it asks for a credit card).');
2101
+ } else {
2102
+ note({ route: null, area: 'runtime', severity: DECLARED, cause: 'a compiled controller uses the Storage facade, but the app binds neither R2 nor D1 — at the edge there is no local filesystem, so Storage::put/get/exists/delete have no object store to back them', fix: 'add the D1 binding (the app ships a D1 blob store, no extra product) or an R2 bucket to wrangler.toml. Until then, Storage facade calls return null at request time.', paths: [{ tier: 'free', what: 'add the D1 binding to wrangler.toml ({"d1_databases":[{"binding":"DB",...}]}) — the app ships a D1 blob store (one row per object in lw_blobs), no extra Cloudflare product' }, { tier: 'paid', what: 'or bind an R2 bucket ({"r2_buckets":[{"binding":"R2",...}]}) — real object storage, asks for a credit card' }] });
2103
+ }
2104
+ }
2105
+ // The compiled cache store is the D1-backed `database` store (the
2106
+ // Laravel DatabaseCacheStore, oracle-verified). A compiled route that
2107
+ // uses the cache on another driver (redis, memcached, file, ...) would
2108
+ // read and write a DIFFERENT store - a silent divergence. The build
2109
+ // stops instead.
2110
+ const usesCache = controllerSources.some((s) => s.includes('rt.cache()'));
2111
+ if (usesCache && runtimeConfig['cache.driver'] !== 'database') {
2112
+ note({ route: null, area: 'config', severity: DECLARED, cause: `a compiled controller uses the cache, but cache.driver is '${runtimeConfig['cache.driver']}' — the compiled rung has exactly one cache store: the D1-backed 'database' store`, fix: 'set CACHE_STORE=database (with the cache + cache_locks tables) or write a port for the route. Until then, cache reads/writes hit a different store than the compiled rung expects — a silent divergence.', paths: [{ tier: 'free', what: 'set CACHE_STORE=database and migrate the cache + cache_locks tables — the compiled rung then reads/writes the D1-backed store' }, { tier: 'free', what: 'or write a port for the route (the port handles the cache)' }] });
2113
+ }
2114
+ }
2115
+ }
2116
+ }
2117
+ }
2118
+
2119
+ // Extra verbs that compiled to their own handler no longer need a port; the rest
2120
+ // stay as partials so `--strict` still fails on them.
2121
+ {
2122
+ const resolved = new Set(verbControllers.map((v) => v.route + ' ' + v.verb));
2123
+ const remaining = [];
2124
+ for (const p of partials) {
2125
+ const left = p.methods.filter((m) => !resolved.has(p.route + ' ' + m));
2126
+ if (left.length) remaining.push({ route: p.route, methods: left });
2127
+ }
2128
+ partials.length = 0;
2129
+ partials.push(...remaining);
2130
+ }
2131
+
2132
+ // Always emit the registries the worker template imports (models, policies,
2133
+ // classes) - even when NO controller compiled and no dump ran. The template is
2134
+ // copied into dist unconditionally, so a missing registry is a bundler error
2135
+ // ("Could not resolve ./classes.generated.mjs") instead of the build naming the
2136
+ // routes it skipped. A real registry (written earlier) wins.
2137
+ if (!existsSync(join(dist, 'src', 'models.generated.mjs'))) {
2138
+ writeFileSync(
2139
+ join(dist, 'src', 'models.generated.mjs'),
2140
+ '// generated by laravel-workers — do not edit\nimport { Model } from \'../kernel/model.mjs\';\n\nexport const models = { };\nexport const routeMap = {};\n'
2141
+ );
2142
+ }
2143
+ if (!existsSync(join(dist, 'src', 'policies.generated.mjs'))) {
2144
+ writeFileSync(
2145
+ join(dist, 'src', 'policies.generated.mjs'),
2146
+ '// generated by laravel-workers — do not edit\nexport const policies = {};\nexport const gateDefinitions = {};\n'
2147
+ );
2148
+ }
2149
+ if (!existsSync(join(dist, 'src', 'classes.generated.mjs'))) {
2150
+ writeFileSync(
2151
+ join(dist, 'src', 'classes.generated.mjs'),
2152
+ '// generated by laravel-workers — do not edit\nexport const classes = {};\nexport const eventListeners = {};\nexport default classes;\n'
2153
+ );
2154
+ }
2155
+
2156
+ // ---------- 3d. schema snapshot ----------
2157
+ // Keep the count so the success footer can name the D1 tables and remind the
2158
+ // operator that the schema statement must be applied ONCE by hand.
2159
+ let schemaTableCount = 0;
2160
+ mkdirSync(cacheDir, { recursive: true });
2161
+ const schemaPath = join(cacheDir, 'schema.json');
2162
+ console.log('\u2022 dumping DB schema');
2163
+ execFileSync(PHP, [join(pkgDir, 'tools', 'schema-dump.php'), schemaPath], {
2164
+ cwd: appDir,
2165
+ env: childEnv(),
2166
+ });
2167
+ cpSync(schemaPath, join(dist, 'schema.json'));
2168
+ console.log(' -> dist/schema.json');
2169
+
2170
+ // The same schema as plain SQL, so the D1 tables can be created with one
2171
+ // command: `wrangler d1 execute DB --file=dist/schema.sql`. The blob store
2172
+ // table (the Storage facade on D1) is included when the app uses Storage::.
2173
+ {
2174
+ let tables = [];
2175
+ try {
2176
+ const snapshot = JSON.parse(readFileSync(schemaPath, 'utf8'));
2177
+ tables = (snapshot.tables || [])
2178
+ .map((t) => String(t.sql || '').trim().replace(/;+$/, ''))
2179
+ .filter(Boolean);
2180
+ schemaTableCount = tables.length;
2181
+ } catch {
2182
+ /* the snapshot is unreadable - dist/schema.json is still written */
2183
+ }
2184
+ const head =
2185
+ '-- laravel-workers: the D1 schema for this app\n' +
2186
+ '-- wrangler d1 execute DB --file=dist/schema.sql\n';
2187
+ const blob = usesStorage
2188
+ ? '\n-- the Storage facade on D1 (one row per object)\n' + BLOB_DDL + ';\n'
2189
+ : '';
2190
+ writeFileSync(
2191
+ join(dist, 'schema.sql'),
2192
+ head + (tables.length ? tables.join(';\n') + ';\n' : '') + blob
2193
+ );
2194
+ console.log(
2195
+ ` -> dist/schema.sql (${tables.length} table(s)` + (usesStorage ? ` + ${BLOB_TABLE}` : '') + ')'
2196
+ );
2197
+ }
2198
+
2199
+ // ---------- 4. copy static assets ----------
2200
+ for (const f of ['build', 'favicon.ico', 'robots.txt']) {
2201
+ const src = join(appDir, 'public', f);
2202
+ if (!existsSync(src)) {
2203
+ console.log(` ! public/${f} missing — skipped`);
2204
+ continue;
2205
+ }
2206
+ cpSync(src, join(dist, 'public', f), { recursive: true });
2207
+ console.log(`• copied public/${f}`);
2208
+ }
2209
+
2210
+ // ---------- 5. worker + ports + routes ----------
2211
+ const workerCandidates = [join(appDir, 'src/worker.mjs'), join(pkgDir, 'src/worker.mjs')];
2212
+ let workerSrc = null;
2213
+ for (const p of workerCandidates) if (existsSync(p)) { workerSrc = p; break; }
2214
+ if (!workerSrc) fail('no worker template found — looked for src/worker.mjs in the app and in the package (reinstall laravel-workers)');
2215
+ cpSync(workerSrc, join(dist, 'src/worker.mjs'));
2216
+ const portsCandidates = [
2217
+ ...(lwPortsDir ? [lwPortsDir] : []),
2218
+ join(appDir, 'src/ports'),
2219
+ join(appDir, 'ports'),
2220
+ join(appDir, 'edge/src/ports'),
2221
+ join(pkgDir, 'src/ports'),
2222
+ ];
2223
+ let portsSrc = null;
2224
+ for (const p of portsCandidates) if (existsSync(p)) { portsSrc = p; break; }
2225
+ if (!portsSrc) portsSrc = join(pkgDir, 'src/ports');
2226
+ if (portsSrc === lwPortsDir) console.log(`• ports: LW_PORTS_DIR -> ${portsSrc}`);
2227
+ const missingPortFiles = portEntries
2228
+ .map(([route, name]) => [route, String(name)])
2229
+ .filter(([, slug]) => !existsSync(join(portsSrc, slug + '.mjs')));
2230
+ if (missingPortFiles.length)
2231
+ fail(
2232
+ 'port file(s) missing in ' + portsSrc + ': ' +
2233
+ missingPortFiles.map(([route, slug]) => `${route} -> ${slug}.mjs`).join(', ') +
2234
+ ' — create them, fix the "ports" mapping in registry.json, or point LW_PORTS_DIR at the right directory'
2235
+ );
2236
+ if (existsSync(portsSrc)) {
2237
+ cpSync(portsSrc, join(dist, 'src/ports'), { recursive: true });
2238
+ } else {
2239
+ mkdirSync(join(dist, 'src/ports'), { recursive: true });
2240
+ console.log(' ! no ports directory found — created empty dist/src/ports');
2241
+ }
2242
+ cpSync(join(pkgDir, 'kernel'), join(dist, 'kernel'), { recursive: true });
2243
+ // kernel/auth.mjs and kernel/hash.mjs import bcryptjs (pure JS, WebCrypto
2244
+ // fallback). The dist tree is what Wrangler bundles, so the package must live
2245
+ // inside it: a bare `bcryptjs` import from <app>/dist resolves only if
2246
+ // <app>/dist/node_modules/bcryptjs exists (the app itself need not depend on it).
2247
+ const bcryptSrc = [join(pkgDir, 'node_modules', 'bcryptjs'), join(appDir, 'node_modules', 'bcryptjs')].find((p) =>
2248
+ existsSync(join(p, 'index.js'))
2249
+ );
2250
+ if (bcryptSrc) {
2251
+ cpSync(bcryptSrc, join(dist, 'node_modules', 'bcryptjs'), { recursive: true });
2252
+ } else {
2253
+ fail(
2254
+ 'bcryptjs is missing — kernel/auth.mjs and kernel/hash.mjs import it, and the dist bundle cannot resolve it.\n' +
2255
+ ' Reinstall laravel-workers (npm i) or add bcryptjs to the app and run npm i.'
2256
+ );
2257
+ }
2258
+ cpSync(join(pkgDir, 'compiler', 'runtime'), join(dist, 'src', 'runtime'), { recursive: true });
2259
+
2260
+ const builtAt = new Date().toISOString();
2261
+ // A uri's base entry answers only its main verb; extra verbs (POST on a
2262
+ // snapshotted GET uri, etc.) get their own table entry below so router.routeKey()
2263
+ // — which keys a handler by the entry's first method — resolves each verb.
2264
+ const baseMethodsFor = (route) => {
2265
+ const all = [...(methodsFor.get(route) || new Set())];
2266
+ const extras = extraVerbsFor(route);
2267
+ if (!extras.length) return all;
2268
+ const kept = all.filter((m) => !extras.includes(m));
2269
+ return kept.length ? kept : all;
2270
+ };
2271
+ const routeTable = routes.map((route) => {
2272
+ if (ignoreMap.some(([r]) => r === route)) {
2273
+ const entry = ignoreMap.find(([r]) => r === route);
2274
+ return { uri: route, ...metaForRoute(route), methods: baseMethodsFor(route), kind: 'ignore', reason: entry[1] };
2275
+ }
2276
+ if (portEntries.some(([r]) => r === route)) {
2277
+ const slug = portEntries.find(([r]) => r === route)[1];
2278
+ return { uri: route, ...metaForRoute(route), methods: baseMethodsFor(route), kind: 'port', controller: slug };
2279
+ }
2280
+ if (viewMap.some(([r]) => r === route)) {
2281
+ const slug = controllerByRoute.get(route);
2282
+ const view = viewMap.find(([r]) => r === route)[1];
2283
+ return { uri: route, ...metaForRoute(route), methods: baseMethodsFor(route), kind: 'view', view, controller: slug };
2284
+ }
2285
+ if (controllerRoutes.has(route)) {
2286
+ const info = controllerRoutes.get(route);
2287
+ return { uri: route, ...metaForRoute(route), methods: baseMethodsFor(route), kind: 'controller', controller: info.slug };
2288
+ }
2289
+ if (staticMap.some(([r]) => r === route)) {
2290
+ const file = staticMap.find(([r]) => r === route)[1];
2291
+ return { uri: route, ...metaForRoute(route), methods: baseMethodsFor(route), kind: 'snapshot', snapshot: file };
2292
+ }
2293
+ if (snapshots.includes(route)) {
2294
+ const file = route === '/' ? 'index.html' : route.slice(1) + '/index.html';
2295
+ return { uri: route, ...metaForRoute(route), methods: baseMethodsFor(route), kind: 'snapshot', snapshot: file };
2296
+ }
2297
+ return { uri: route, ...metaForRoute(route), methods: baseMethodsFor(route), kind: 'unhandled' };
2298
+ });
2299
+ for (const v of verbControllers) {
2300
+ const meta = metaForVerb(v.route, v.verb) || {};
2301
+ routeTable.push({
2302
+ uri: v.route,
2303
+ name: meta.name ?? null,
2304
+ domain: meta.domain ?? null,
2305
+ middleware: meta.middleware ?? null,
2306
+ methods: [v.verb],
2307
+ kind: 'controller',
2308
+ controller: v.slug,
2309
+ });
2310
+ }
2311
+
2312
+ // ---------- 5a. middleware audit ----------
2313
+ // kernel/http.mjs honours exactly this set. Anything else on a view / controller /
2314
+ // snapshot route would be bypassed at the edge — fail the build instead.
2315
+ {
2316
+ const isNumericThrottle = (m) => {
2317
+ const str = String(m);
2318
+ if (str.split(':')[0] !== 'throttle') return false;
2319
+ const spec = str.slice('throttle'.length).replace(/^:/, '');
2320
+ return spec === '' || /^\d+(,\d+)?$/.test(spec);
2321
+ };
2322
+ const RUNTIME_MIDDLEWARE = new Set(['web', 'api', 'substitute_bindings', 'bindings', 'auth', 'auth.basic', 'guest']);
2323
+ const offenders = [];
2324
+ let throttleRoutes = 0;
2325
+ for (const entry of routeTable) {
2326
+ if (entry.kind !== 'view' && entry.kind !== 'controller' && entry.kind !== 'snapshot') continue;
2327
+ const bad = (entry.middleware || []).filter(
2328
+ (m) => !RUNTIME_MIDDLEWARE.has(String(m).split(':')[0]) && !isNumericThrottle(m)
2329
+ );
2330
+ if (bad.length) {
2331
+ offenders.push(`${entry.uri} [${bad.join(', ')}] (${entry.kind})`);
2332
+ for (const m of bad) note({ route: entry.uri, area: 'middleware', severity: BLOCK, cause: `runs the "${m}" middleware, which the compiled rung does not honour`, fix: MIDDLEWARE_FIX(m, entry.uri) });
2333
+ }
2334
+ if ((entry.middleware || []).some(isNumericThrottle)) throttleRoutes++;
2335
+ }
2336
+ if (offenders.length) {
2337
+ fail(
2338
+ 'route middleware the compiled rung does not honour:\n - ' +
2339
+ offenders.join('\n - ') +
2340
+ '\n add a port for these routes, or drop the middleware.\n' +
2341
+ ' (named rate limiters — throttle:name — need their AppServiceProvider\n' +
2342
+ ' definition; the build only honours numeric throttle:n,m)'
2343
+ );
2344
+ }
2345
+ // `throttle` reads/writes the D1-backed `database` cache store. Without it,
2346
+ // the compiled limiter 500s at request time — but the operator can rewrite
2347
+ // the app (set CACHE_STORE=database) to make it work. So this is a declared
2348
+ // gap, not a hard block: the build ships, and the operator knows exactly
2349
+ // what to do to close the gap.
2350
+ if (throttleRoutes && runtimeConfig['cache.driver'] !== 'database') {
2351
+ note({ route: null, area: 'config', severity: DECLARED, cause: `${throttleRoutes} route(s) use throttle middleware, but cache.driver is '${runtimeConfig['cache.driver']}' — the compiled limiter needs the D1-backed 'database' store`, fix: 'set CACHE_STORE=database and migrate the cache + cache_locks tables (the free path) — or add a port for the throttled routes. Until then the throttled routes 500 at request time.', paths: [{ tier: 'free', what: 'set CACHE_STORE=database in the app and migrate the cache + cache_locks tables — the compiled limiter then reads/writes D1' }, { tier: 'free', what: 'or add a port for the throttled route(s) — the port handles the throttling' }] });
2352
+ }
2353
+ }
2354
+
2355
+ const controllerImports = [];
2356
+ const controllerMap = [];
2357
+ let controllerIndex = 0;
2358
+ for (const entry of routeTable) {
2359
+ if (entry.kind === 'view' || entry.kind === 'port' || entry.kind === 'controller') {
2360
+ const idx = controllerIndex++;
2361
+ controllerImports.push(`import handler${idx} from './controllers/${entry.controller}.mjs';`);
2362
+ controllerMap.push({ uri: entry.uri, methods: entry.methods, idx });
2363
+ }
2364
+ }
2365
+
2366
+ const routeNames = {};
2367
+ for (const [key, meta] of metaFor) {
2368
+ if (meta.name) routeNames[meta.name] = key.slice(key.indexOf(' ') + 1);
2369
+ }
2370
+
2371
+ const handlersLines = controllerMap.map(({ uri, methods, idx }) => {
2372
+ const m = methods && methods.length ? methods : ['GET'];
2373
+ return ` ${JSON.stringify(m[0] + ' ' + uri)}: handler${idx},`;
2374
+ });
2375
+
2376
+ writeFileSync(
2377
+ join(dist, 'src', 'routes.generated.mjs'),
2378
+ [
2379
+ '// generated by build.mjs — do not edit',
2380
+ ...controllerImports,
2381
+ `export const builtAt = ${JSON.stringify(builtAt)};`,
2382
+ 'export const routeTable = ' + JSON.stringify(routeTable, null, 2) + ';',
2383
+ 'export const routeNames = ' + JSON.stringify(routeNames, null, 2) + ';',
2384
+ 'export const handlers = {',
2385
+ ...handlersLines,
2386
+ '};',
2387
+ '',
2388
+ ].join('\n')
2389
+ );
2390
+ // ---------- 5b. Cloudflare Static Assets routing ----------
2391
+ mkdirSync(join(dist, 'public'), { recursive: true });
2392
+ const routesJson = buildRoutesJson(routeTable, { publicDirs: ['build'], extraInclude: ['/health'] });
2393
+ writeFileSync(join(dist, 'public', '_routes.json'), JSON.stringify(routesJson, null, 2) + '\n');
2394
+ console.log(` -> dist/public/_routes.json (${(routesJson.include || []).length} include, ${(routesJson.exclude || []).length} exclude)`);
2395
+
2396
+ // The action for an unhandled route. The reason already says WHY; this says
2397
+ // WHAT TO DO, and the four cases are genuinely different (a dynamic segment is
2398
+ // a five-minute port; a route that answers 500 at build time is a bug in the
2399
+ // app that the build just found for free).
2400
+ const needsPortFix = (n) => {
2401
+ const reason = String(n.reason || '');
2402
+ const ignore = `{"ignore": {"${n.route}": "<why>"}}`;
2403
+ if (reason.startsWith('dynamic segment')) return `write a port — the worker router matches dynamic segments and hands them to the handler as ctx.params (build --scaffold creates the file), or ${ignore}`;
2404
+ if (reason.startsWith('no GET')) return `the route has no GET to snapshot — write a port for the verb(s) it does answer, or ${ignore}`;
2405
+ if (reason.startsWith('unhandled (autoSnapshot: false)')) return `registry.json sets "autoSnapshot": false, so nothing is snapshotted — write a port, or set autoSnapshot back to true`;
2406
+ if (reason.startsWith('a controller serves it')) return 'the controller did not compile — the reason is on its `controller ... skipped` line above; fix that, or write a port';
2407
+ if (reason.startsWith('HTTP 5')) {
2408
+ const dbHint = /no such table/i.test(reason)
2409
+ ? ' — the database is missing tables: run `php artisan migrate` first (a fresh clone starts with an empty database)'
2410
+ : '';
2411
+ return `the route throws when the app boots at build time (${reason}) — this is a bug the build found for free; fix the route, then rebuild${dbHint}`;
2412
+ }
2413
+ if (reason.startsWith('HTTP 3')) return `the route redirects (${reason}) — a redirect needs a handler: write a port that returns it, or ${ignore}`;
2414
+ if (reason.startsWith('HTTP 4')) return `the route answers ${reason} for a bare GET — it probably needs a parameter or auth; write a port, or ${ignore}`;
2415
+ if (reason.startsWith('HTTP')) return `the response is not an HTML page (${reason}) — write a port that returns it (a JSON or file body), or ${ignore}`;
2416
+ return `write a port (build --scaffold), or ${ignore}`;
2417
+ };
2418
+
2419
+ // ---------- 6. coverage report ----------
2420
+ const unknown = [
2421
+ ...new Set([
2422
+ ...staticMap.map(([r]) => r),
2423
+ ...portEntries.map(([r]) => r),
2424
+ ...viewMap.map(([r]) => r),
2425
+ ...ignoreMap.map(([r]) => r),
2426
+ ]),
2427
+ ].filter((r) => !routes.includes(r));
2428
+ const stubPorts = findStubPorts(portsSrc);
2429
+ const runtimeBinding = needsPort.filter((n) => isRuntimeBindingError(n.reason));
2430
+ const blocked = (needsPort.length - runtimeBinding.length) > 0 || partials.length > 0 || unknown.length > 0 || stubPorts.length > 0 || uncompiledExtraNonPost.length > 0;
2431
+
2432
+ if (snapshots.length) console.log(`• auto-snapshots (${snapshots.length}): ${snapshots.join(', ')}`);
2433
+ for (const p of partials)
2434
+ console.log(` WARN ${p.route}: GET is snapshotted, but ${p.methods.join(', ')} needs a port`);
2435
+ for (const x of uncompiledExtraNonPost)
2436
+ console.log(` WARN ${x.route}: ${x.verb} needs a port (only GET/POST handlers are compiled)`);
2437
+ for (const n of needsPort) console.log(` UNHANDLED ${n.route}: ${n.reason}`);
2438
+ if (needsPort.length)
2439
+ console.log(' -> add a port, a "static" entry, or "ignore" (with a reason) in registry.json');
2440
+ if (unknown.length) console.log(` registry entries not found in route:list: ${unknown.join(', ')}`);
2441
+ if (stubPorts.length)
2442
+ console.log(
2443
+ ` STUB ${stubPorts.length} port(s) not implemented yet (returning 501): ${stubPorts
2444
+ .map((n) => relative(appDir, join(portsSrc, n)))
2445
+ .join(', ')}`
2446
+ );
2447
+ if (!blocked)
2448
+ console.log(
2449
+ `• coverage: all ${routes.length} route(s) handled — static ${staticMap.length}, snapshots ${snapshots.length}, ports ${portEntries.length}, controllers ${controllerRoutes.size}, extra-verb controllers ${verbControllers.length}, ignored ${ignoreMap.length}`
2450
+ );
2451
+
2452
+ // Every undecided route becomes a finding with the action that resolves it.
2453
+ // The reasons above are already specific ("dynamic segment", "HTTP 500"); what
2454
+ // the operator lacks is the STEP, so each row names one.
2455
+ for (const n of needsPort) {
2456
+ if (isRuntimeBindingError(n.reason)) {
2457
+ note({ route: n.route, area: 'route', severity: DECLARED, cause: `needs a runtime binding (auto-gap): ${n.reason}`, fix: 'declare the binding in wrangler.jsonc (d1_databases) or set the env at deploy time; the build cannot prerender a route that needs a live connection' });
2458
+ } else {
2459
+ note({ route: n.route, area: 'route', severity: BLOCK, cause: `no handler: ${n.reason}`, fix: needsPortFix(n) });
2460
+ }
2461
+ // A route that never became a snapshot or a controller skips the middleware
2462
+ // audit above (that one walks the route TABLE), so its middleware is never
2463
+ // named - and the reason it could not be served is usually the middleware,
2464
+ // not the status it happened to answer. `verified` reads as "HTTP 500"; the
2465
+ // truth is a stage the compiled rung does not run.
2466
+ for (const m of middlewareForRoute(n.route) || []) {
2467
+ if (isHonouredMiddleware(m)) continue;
2468
+ note({ route: n.route, area: 'middleware', severity: BLOCK, cause: `runs the "${m}" middleware, which the compiled rung does not honour`, fix: MIDDLEWARE_FIX(m, n.route) });
2469
+ }
2470
+ }
2471
+ for (const p of partials) note({ route: p.route, area: 'route', severity: BLOCK, cause: `GET is snapshotted, but ${p.methods.join(', ')} has no handler`, fix: `write a port for ${p.methods.join('/')} ${p.route} (build --scaffold), or ignore the route with a reason` });
2472
+ for (const x of uncompiledExtraNonPost) note({ route: x.route, area: 'route', severity: BLOCK, cause: `${x.verb} has no handler (only GET/POST/PUT/PATCH/DELETE compile)`, fix: `write a port for ${x.verb} ${x.route}, or ignore the route with a reason` });
2473
+ for (const u of unknown) note({ route: u, area: 'registry', severity: BLOCK, cause: 'registry.json names this route, but `artisan route:list` has no such route', fix: 'fix the path in registry.json (it must match route:list exactly), or delete the entry' });
2474
+ for (const ss of stubPorts) note({ route: null, area: 'route', severity: BLOCK, cause: `the port ${relPath(join(portsSrc, ss))} is still the scaffold stub (it answers 501)`, fix: 'implement the handler against env.DB (or another binding), then re-run the build' });
2475
+ // An `ignore` is a decision, not a failure: it ships, so it is reported as a
2476
+ // declared gap and never changes a green build into a red one.
2477
+ for (const [route, reason] of ignoreMap) note({ route, area: 'route', severity: DECLARED, cause: `ignored -- ${reason || 'no reason given'}`, fix: reason ? null : 'registry.json: give the "ignore" entry a reason (the next reader needs it)' });
2478
+
2479
+
2480
+ // ---------- 6b. app-source sweep (advisory only) ----------------------------
2481
+ // The compiler is the authority on what compiles. This sweep is not: it names
2482
+ // constructs the sandbox CANNOT ever carry even if the route is compiled, so
2483
+ // the operator fixes them before hitting the first build failure. Advisory
2484
+ // means severity = ADVISORY: green builds are not turned red by this list.
2485
+ // We walk only app/ (not vendor/) because everything outside the app is
2486
+ // framework code -- the build already refuses or ignores it correctly.
2487
+ {
2488
+ const sweepDir = join(appDir, 'app');
2489
+ if (existsSync(sweepDir)) {
2490
+ const files = [];
2491
+ const walk = (d) => {
2492
+ for (const e of readdirSync(d)) {
2493
+ const p = join(d, e);
2494
+ if (lstatSync(p).isDirectory()) {
2495
+ walk(p);
2496
+ continue;
2497
+ }
2498
+ if (!e.endsWith('.php')) continue;
2499
+ try {
2500
+ files.push({ file: relPath(p), source: readFileSync(p, 'utf8') });
2501
+ } catch {
2502
+ /* unreadable file: not a build input */
2503
+ }
2504
+ }
2505
+ };
2506
+ walk(sweepDir);
2507
+ for (const f of sweepAppSources(files)) note(f);
2508
+ }
2509
+ }
2510
+
2511
+ // ---------- 7. scaffold stubs for routes that need a port ----------
2512
+
2513
+ if (scaffold && needsPort.length) {
2514
+ const slugify = (route) =>
2515
+ route.replace(/^\//, '').replace(/[{}]/g, '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'home';
2516
+ const entries = needsPort.map(({ route }) => ({ route, slug: slugify(route) }));
2517
+ for (const { route, slug } of entries) {
2518
+ const stubDir = isNpmMode ? (portsSrc !== join(pkgDir, 'src/ports') ? portsSrc : join(appDir, 'src/ports')) : join(pkgDir, 'src/ports');
2519
+ mkdirSync(stubDir, { recursive: true });
2520
+ const stub = join(stubDir, `${slug}.mjs`);
2521
+ if (existsSync(stub)) {
2522
+ console.log(` scaffold: ${relative(appDir, stub)} already exists`);
2523
+ } else {
2524
+ writeFileSync(stub, buildStubSource(route));
2525
+ console.log(` scaffolded ${relative(appDir, stub)} (${STUB_MARKER} — build --strict fails until implemented)`);
2526
+ }
2527
+ }
2528
+ const { registry: nextRegistry, added } = mergePorts(registry, entries);
2529
+ if (added.length) {
2530
+ writeFileSync(registryPath, serializeRegistry(nextRegistry));
2531
+ for (const { route, slug } of added) {
2532
+ console.log(` registry.json: "ports" += ${JSON.stringify(route)}: ${JSON.stringify(slug)}`);
2533
+ }
2534
+ } else {
2535
+ console.log(' registry.json: all scaffolded ports already registered');
2536
+ }
2537
+ }
2538
+ if (blocked && !scaffold) {
2539
+ if (needsPort.length) console.log(' (tip: re-run with --scaffold to generate + register port stubs)');
2540
+ else if (stubPorts.length) console.log(' (tip: implement the stub port(s), then re-run)');
2541
+ }
2542
+ if (strict && blocked) {
2543
+ const stubNote = stubPorts.length
2544
+ ? ` — ${stubPorts.length} port(s) are still scaffold stubs (returning 501): ${stubPorts.join(', ')}`
2545
+ : '';
2546
+ // Count the findings themselves, not the route arrays - the routes were
2547
+ // re-checked into multiple findings (one per unresolved middleware), and
2548
+ // that's what the readiness block above prints.
2549
+ const blocking = findings.filter((f) => f.severity === BLOCK).length;
2550
+ fail(
2551
+ 'coverage check failed (--strict)' +
2552
+ ` — ${blocking} finding(s) need a decision, listed above` +
2553
+ (scaffold ? ' — port stubs were scaffolded + registered; implement them, then re-run' : '') +
2554
+ stubNote
2555
+ );
2556
+ }
2557
+
2558
+ // A queued job with no cron trigger never runs: the route would answer 200 and
2559
+ // the work would silently never happen (Laravel runs it). This runs LAST -
2560
+ // `queuePushes` is filled while the controllers are emitted.
2561
+ if (queuePushes.length && !(cronTriggers && cronTriggers.length)) {
2562
+ for (const route of queuePushes) note({ route, area: 'queue', severity: BLOCK, cause: 'dispatches a queued job, but the worker has no cron trigger', fix: 'add `[triggers] crons = ["*/1 * * * *"]` to wrangler.toml (a Worker has no long-lived consumer), or dispatch the job inline (QUEUE_CONNECTION=sync)', paths: [{ tier: 'free', what: 'add `[triggers] crons = ["*/1 * * * *"]` to wrangler.toml - the compiled rung writes the job to the D1 lw_jobs table and the cron trigger drains it (free accounts get 5 triggers)' }, { tier: 'free', what: 'or dispatch inline with QUEUE_CONNECTION=sync when the work is small enough to finish in the request' }, { tier: 'paid', what: 'Cloudflare Queues with a consumer you write - charged per operation' }] });
2563
+ fail(
2564
+ 'the route(s) ' + queuePushes.join(', ') + ' dispatch a queued job, but the worker has no cron trigger - ' +
2565
+ 'a Worker has no long-lived queue consumer, so the job would never run. Add a cron trigger ' +
2566
+ '(wrangler.toml `[triggers] crons = ["*/1 * * * *"]`) or dispatch the job inline ' +
2567
+ "(Laravel's sync driver) instead"
2568
+ );
2569
+ }
2570
+
2571
+ // ---------- the swap ----------
2572
+ // Every check passed and every artifact landed in the staging directory: move
2573
+ // the previous `dist/` aside and put the new one in its place. A build that
2574
+ // failed above never reaches this line, so `dist/` still holds the last build
2575
+ // that COMPLETED (compiler/src/dist-swap.mjs).
2576
+ commitDist(dist, distFinal, (file) =>
2577
+ console.log(
2578
+ ` (warning: ${relative(appDir, file)} is locked by a running process — restart the dev server if build output looks stale)`
2579
+ )
2580
+ );
2581
+
2582
+ console.log(
2583
+ `done. dist: ${relative(appDir, distFinal)} (static: ${staticMap.length}, snapshots: ${snapshots.length}, ports: ${portEntries.length}, controllers: ${controllerRoutes.size}, ignored: ${ignoreMap.length})`
2584
+ );
2585
+
2586
+ // The build is green: say what that green MEANS. A green build with two
2587
+ // ignored routes is not the same product as one with none, and the operator
2588
+ // must see the difference here - not discover it at the edge.
2589
+ console.log(renderReadiness(findings));
2590
+
2591
+ // The one thing every first deploy needs and no build can do for the operator:
2592
+ // the D1 tables. Printed only when there is a schema to apply.
2593
+ if (schemaTableCount) {
2594
+ console.log(
2595
+ ` schema: ${relPath(join(distFinal, 'schema.sql'))} (${schemaTableCount} table(s)) -- apply ONCE, by hand:\n` +
2596
+ ' npx wrangler d1 execute DB --file=dist/schema.sql --remote'
2597
+ );
2598
+ }
2599
+ console.log(' next: npx wrangler dev (local) npx wrangler deploy (edge)');