tw-framework 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/testing.cjs CHANGED
@@ -78,11 +78,158 @@ function setSignal(name, value, opts) {
78
78
  }
79
79
  return getSignalHub().setSignal(name, value, session ? { session } : void 0);
80
80
  }
81
- var BATCH_MS, HISTORY_LIMIT, SignalHub, hub;
81
+ function sseFrameWithId(payload) {
82
+ try {
83
+ const seq = JSON.parse(payload).seq;
84
+ if (typeof seq === "number") return "id: " + seq + "\ndata: " + payload + "\n\n";
85
+ } catch {
86
+ }
87
+ return "data: " + payload + "\n\n";
88
+ }
89
+ function createSignalStream(request, url, opts) {
90
+ const hub2 = getSignalHub();
91
+ if (opts && !hub2.__cfg) {
92
+ hub2.__cfg = true;
93
+ try {
94
+ hub2.configure({ maxClients: opts.maxClients, maxHistory: opts.maxHistory });
95
+ } catch {
96
+ }
97
+ }
98
+ if (url.searchParams.get("stats")) {
99
+ return new Response(JSON.stringify(hub2.getStats(), null, 2), {
100
+ status: 200,
101
+ headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }
102
+ });
103
+ }
104
+ if (!hub2.canAcceptClient()) {
105
+ return new Response("Too many signal-stream connections", {
106
+ status: 503,
107
+ headers: { "Content-Type": "text/plain", "Retry-After": "5" }
108
+ });
109
+ }
110
+ const names = (url.searchParams.get("s") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
111
+ let since = Number(url.searchParams.get("since") ?? "0") || 0;
112
+ if (!since) {
113
+ const lei = Number(request.headers.get("last-event-id") ?? "0") || 0;
114
+ if (lei) since = lei;
115
+ }
116
+ const declared = /* @__PURE__ */ new Map();
117
+ for (const n of names) declared.set(n, "public");
118
+ let session;
119
+ try {
120
+ const header = request.headers.get("cookie") ?? "";
121
+ const m = /(?:^|;\s*)tw_session=([^;]+)/.exec(header);
122
+ if (m) session = decodeURIComponent(m[1].trim().replace(/^"|"$/g, ""));
123
+ } catch {
124
+ }
125
+ const enc = new TextEncoder();
126
+ const sse = (payload) => enc.encode(sseFrameWithId(payload));
127
+ let registered = null;
128
+ let keepAlive = null;
129
+ const stream = new ReadableStream({
130
+ start(controller) {
131
+ const client = hub2.register(declared, session);
132
+ registered = client;
133
+ const cleanup = () => {
134
+ if (keepAlive) clearInterval(keepAlive);
135
+ hub2.unregister(client.id);
136
+ try {
137
+ controller.close();
138
+ } catch {
139
+ }
140
+ };
141
+ client.send = (payload) => {
142
+ try {
143
+ controller.enqueue(sse(payload));
144
+ } catch {
145
+ cleanup();
146
+ }
147
+ };
148
+ client.close = cleanup;
149
+ for (const payload of hub2.connectPayloads(since, declared, session)) {
150
+ try {
151
+ controller.enqueue(sse(payload));
152
+ } catch {
153
+ break;
154
+ }
155
+ }
156
+ keepAlive = setInterval(() => {
157
+ try {
158
+ controller.enqueue(enc.encode(": keep-alive\n\n"));
159
+ } catch {
160
+ cleanup();
161
+ }
162
+ }, 25e3);
163
+ },
164
+ // v2: when the browser drops the connection (tab close, network
165
+ // loss) cancel fires -- remove THIS hub entry immediately instead
166
+ // of waiting for the next failed send.
167
+ cancel() {
168
+ if (keepAlive) clearInterval(keepAlive);
169
+ try {
170
+ registered?.close?.();
171
+ } catch {
172
+ }
173
+ }
174
+ });
175
+ return new Response(stream, {
176
+ status: 200,
177
+ headers: {
178
+ "Content-Type": "text/event-stream; charset=utf-8",
179
+ "Cache-Control": "no-store",
180
+ "X-Accel-Buffering": "no",
181
+ Connection: "keep-alive"
182
+ }
183
+ });
184
+ }
185
+ async function writeSignalFromClient(request, opts) {
186
+ if (!opts?.clientWrites) {
187
+ return new Response(JSON.stringify({ error: "client signal writes are disabled" }), {
188
+ status: 403,
189
+ headers: { "Content-Type": "application/json" }
190
+ });
191
+ }
192
+ let body = null;
193
+ try {
194
+ body = await request.json();
195
+ } catch {
196
+ }
197
+ const name = body?.name;
198
+ if (typeof name !== "string" || !("value" in (body ?? {}))) {
199
+ return new Response(JSON.stringify({ error: "body must be { name, value }" }), {
200
+ status: 400,
201
+ headers: { "Content-Type": "application/json" }
202
+ });
203
+ }
204
+ const hub2 = getSignalHub();
205
+ const kind = (hub2.getStats().signals ?? {})[name];
206
+ if (kind && kind !== "public") {
207
+ return new Response(JSON.stringify({ error: "only public signals are client-writable" }), {
208
+ status: 403,
209
+ headers: { "Content-Type": "application/json" }
210
+ });
211
+ }
212
+ const res = hub2.setSignal(name, body.value);
213
+ if (!res.queued) {
214
+ return new Response(JSON.stringify({ error: res.error ?? "rejected" }), {
215
+ status: 400,
216
+ headers: { "Content-Type": "application/json" }
217
+ });
218
+ }
219
+ return new Response(JSON.stringify({ ok: true, kind: res.kind }), {
220
+ status: 202,
221
+ headers: { "Content-Type": "application/json" }
222
+ });
223
+ }
224
+ var BATCH_MS, HISTORY_LIMIT, MAX_CLIENTS, MAX_VALUE_BYTES, MAX_NAME_LEN, NAME_RE, SignalHub, hub;
82
225
  var init_signal_stream = __esm({
83
226
  "../../packages/server/tw/routing/signal-stream.ts"() {
84
227
  BATCH_MS = 100;
85
228
  HISTORY_LIMIT = 1e3;
229
+ MAX_CLIENTS = 500;
230
+ MAX_VALUE_BYTES = 64 * 1024;
231
+ MAX_NAME_LEN = 64;
232
+ NAME_RE = /^[A-Za-z0-9_$.-]{1,64}$/;
86
233
  SignalHub = class {
87
234
  clients = /* @__PURE__ */ new Map();
88
235
  nextClientId = 1;
@@ -93,12 +240,82 @@ var init_signal_stream = __esm({
93
240
  pending = /* @__PURE__ */ new Map();
94
241
  pendingSession;
95
242
  timer = null;
243
+ // v2 stats + caps
244
+ maxClients = MAX_CLIENTS;
245
+ createdAt = Date.now();
246
+ totalUpdates = 0;
247
+ batchesFlushed = 0;
248
+ rejectedCount = 0;
249
+ /** v2: configure caps (from tw.config.ts signalStream options). */
250
+ configure(opts) {
251
+ if (opts?.maxClients && opts.maxClients > 0) this.maxClients = opts.maxClients;
252
+ if (opts?.maxHistory && opts.maxHistory > 0) this.historyLimit = opts.maxHistory;
253
+ }
254
+ historyLimit = HISTORY_LIMIT;
255
+ /** v2: observability for /_tw/stream?stats=1 and window.__tw stats. */
256
+ getStats() {
257
+ const signals = {};
258
+ for (const [n, k] of this.kinds) signals[n] = k;
259
+ return {
260
+ clients: this.clients.size,
261
+ clientCap: this.maxClients,
262
+ seq: this.seq,
263
+ signals,
264
+ valueCount: this.values.size,
265
+ history: this.history.length,
266
+ historyCap: this.historyLimit,
267
+ pending: this.pending.size,
268
+ totalUpdates: this.totalUpdates,
269
+ batchesFlushed: this.batchesFlushed,
270
+ rejected: this.rejectedCount,
271
+ uptimeMs: Date.now() - this.createdAt
272
+ };
273
+ }
274
+ /** v2: graceful shutdown -- close every connected stream now. */
275
+ closeAll() {
276
+ for (const c of [...this.clients.values()]) {
277
+ try {
278
+ c.close();
279
+ } catch {
280
+ }
281
+ }
282
+ this.clients.clear();
283
+ if (this.timer != null) {
284
+ clearTimeout(this.timer);
285
+ this.timer = null;
286
+ }
287
+ this.pending.clear();
288
+ }
289
+ /** v2: connection cap check BEFORE registering a stream client. */
290
+ canAcceptClient() {
291
+ return this.clients.size < this.maxClients;
292
+ }
293
+ /** v2: validate a setSignal name/value without throwing. */
294
+ validateSignal(name, value) {
295
+ if (typeof name !== "string" || name.length === 0 || name.length > MAX_NAME_LEN) {
296
+ return "signal name must be 1-" + MAX_NAME_LEN + " chars";
297
+ }
298
+ if (!NAME_RE.test(name)) return "signal name has invalid characters";
299
+ if (value === void 0) return "signal value is undefined";
300
+ try {
301
+ const size = typeof value === "string" ? value.length : JSON.stringify(value)?.length ?? 0;
302
+ if (size > MAX_VALUE_BYTES) return "signal value exceeds " + MAX_VALUE_BYTES + " bytes";
303
+ } catch {
304
+ return "signal value is not serializable";
305
+ }
306
+ return null;
307
+ }
96
308
  /**
97
309
  * Server-side mutation (route handlers): queue an update, batch-flush.
98
310
  * With `session` (derived from the request's tw_session cookie) private
99
311
  * signals are delivered only to clients authenticated as that session.
100
312
  */
101
313
  setSignal(name, value, opts) {
314
+ const invalid = this.validateSignal(name, value);
315
+ if (invalid) {
316
+ this.rejectedCount++;
317
+ return { queued: false, kind: this.kinds.get(name), error: invalid };
318
+ }
102
319
  const kind = this.kinds.get(name) ?? "public";
103
320
  this.values.set(name, value);
104
321
  this.kinds.set(name, kind);
@@ -129,9 +346,27 @@ var init_signal_stream = __esm({
129
346
  this.clients.set(id, client);
130
347
  return client;
131
348
  }
349
+ /** v2: remove a stream client (its connection died / was cancelled).
350
+ * The old handler overwrote client.close() with a closure that only
351
+ * closed the controller -- the Map entry leaked forever, so every
352
+ * disconnected browser still occupied a slot (and got flushed frames).
353
+ */
354
+ unregister(id) {
355
+ this.clients.delete(id);
356
+ }
132
357
  /** Build the replay/snapshot preamble for a connecting client. */
133
358
  connectPayloads(since, declared, session) {
134
359
  const out = [];
360
+ if (since <= 0 && this.values.size > 0) {
361
+ const snap = {};
362
+ for (const [name, value] of this.values) {
363
+ if (this.sessionMatches(session, { names: [name], session: this.valueSessions.get(name) }) && this.deliversTo(declared, [name])) snap[name] = value;
364
+ }
365
+ if (Object.keys(snap).length > 0) {
366
+ out.push(this.frame({ v: 1, seq: this.seq, snapshot: snap }));
367
+ }
368
+ return out;
369
+ }
135
370
  const hasCoverage = since <= this.seq && this.history.length > 0 && since >= this.history[0].seq - 1;
136
371
  if (hasCoverage) {
137
372
  for (const entry of this.history) {
@@ -174,7 +409,9 @@ var init_signal_stream = __esm({
174
409
  for (const [n] of updates) this.valueSessions.set(n, session);
175
410
  const payload = this.frame({ v: 1, seq: this.seq, updates });
176
411
  this.history.push({ seq: this.seq, payload, names, session });
177
- if (this.history.length > HISTORY_LIMIT) this.history.shift();
412
+ this.totalUpdates += updates.length;
413
+ this.batchesFlushed++;
414
+ if (this.history.length > this.historyLimit) this.history.shift();
178
415
  for (const client of this.clients.values()) {
179
416
  if (this.sessionMatches(client.session, { names, session }) && this.deliversTo(client.declared, names)) client.send(payload);
180
417
  }
@@ -331,6 +568,8 @@ function matchPath(pattern, pathname) {
331
568
  }
332
569
  function ruleBlocked(rule, request, pathname) {
333
570
  const headers = request.headers;
571
+ const hasCondition = rule.userAgent || rule.path || rule.auth || rule.rateLimit || rule.origin || rule.methods && rule.methods.length > 0;
572
+ if (!hasCondition) return true;
334
573
  if (rule.userAgent) {
335
574
  const ua = (headers.get("user-agent") ?? "").toLowerCase();
336
575
  if (!ua) {
@@ -14584,6 +14823,13 @@ function consumeSignalDeclaration(cursor) {
14584
14823
  function consumeStateValue(cursor) {
14585
14824
  const t2 = cursor.peek();
14586
14825
  if (!t2) return "";
14826
+ if (t2.value === "-" || t2.value === "+") {
14827
+ const after = cursor.peek(1);
14828
+ if (after && (after.type === "NUMBER" || after.type === "IDENT" || after.type === "KEYWORD")) {
14829
+ cursor.advance();
14830
+ return (t2.value === "-" ? "-" : "") + String(after.value);
14831
+ }
14832
+ }
14587
14833
  if (t2.type === "STRING" || t2.type === "NUMBER" || t2.type === "IDENT" || t2.type === "KEYWORD") {
14588
14834
  cursor.advance();
14589
14835
  if (t2.type === "STRING") return String(t2.value).replace(/\\([{}"])/g, "$1");
@@ -15601,6 +15847,20 @@ function parseTWSelector(cursor, errors, state, parseBody) {
15601
15847
  }
15602
15848
  cursor.consumeIf("RBRACE");
15603
15849
  } else {
15850
+ for (; ; ) {
15851
+ const t2 = cursor.peek();
15852
+ if (!t2 || t2.type === "RBRACE" || t2.type === "EOF") break;
15853
+ if ((t2.type === "IDENT" || t2.type === "KEYWORD") && ATTR_PAIR_NAMES.has(t2.value)) {
15854
+ const after = cursor.peek(1);
15855
+ if (after && (after.type === "STRING" || after.type === "NUMBER")) {
15856
+ cursor.advance();
15857
+ cursor.advance();
15858
+ el.attrs.push(createAttribute(t2.value, after.value, t2.pos.line, t2.pos.col));
15859
+ continue;
15860
+ }
15861
+ }
15862
+ break;
15863
+ }
15604
15864
  el.children = parseBody(["RBRACE"]);
15605
15865
  cursor.consumeIf("RBRACE");
15606
15866
  }
@@ -16219,10 +16479,13 @@ function parseWhileStatement(cursor, errors, parseBody) {
16219
16479
  const whileToken = cursor.advance();
16220
16480
  if (!whileToken) return null;
16221
16481
  cursor.skipWhitespace();
16222
- const condToken = cursor.peek();
16223
16482
  let condition = "";
16224
- if (condToken) {
16225
- condition = condToken.value;
16483
+ while (!cursor.done) {
16484
+ cursor.skipWhitespace();
16485
+ const t2 = cursor.peek();
16486
+ if (!t2 || t2.type === "EOF") break;
16487
+ if (t2.type === "LBRACE" || t2.value === "{") break;
16488
+ condition += (condition ? " " : "") + t2.value;
16226
16489
  cursor.advance();
16227
16490
  }
16228
16491
  const body = parseBlockBody2(cursor, parseBody);
@@ -16343,13 +16606,133 @@ function parseBlockBody2(cursor, parseBody) {
16343
16606
  }
16344
16607
  return parseBody(["KEYWORD", "RBRACE"]);
16345
16608
  }
16346
- var CLASS_BOUNDARY_BOOLEAN_ATTRS;
16609
+ var ATTR_PAIR_NAMES, CLASS_BOUNDARY_BOOLEAN_ATTRS;
16347
16610
  var init_statements = __esm({
16348
16611
  "../../packages/compiler/tw/parser/statements/index.ts"() {
16349
16612
  init_nodes();
16350
16613
  init_recovery();
16351
16614
  init_directives();
16352
16615
  init_tw();
16616
+ ATTR_PAIR_NAMES = /* @__PURE__ */ new Set([
16617
+ "href",
16618
+ "src",
16619
+ "type",
16620
+ "id",
16621
+ "name",
16622
+ "value",
16623
+ "placeholder",
16624
+ "action",
16625
+ "method",
16626
+ "title",
16627
+ "alt",
16628
+ "rel",
16629
+ "target",
16630
+ "lang",
16631
+ "charset",
16632
+ "content",
16633
+ "width",
16634
+ "height",
16635
+ "loading",
16636
+ "for",
16637
+ "role",
16638
+ "min",
16639
+ "max",
16640
+ "step",
16641
+ "pattern",
16642
+ "accept",
16643
+ "list",
16644
+ "download",
16645
+ "tabindex",
16646
+ "cols",
16647
+ "rows",
16648
+ "wrap",
16649
+ "datetime",
16650
+ "method",
16651
+ "action",
16652
+ "placeholder",
16653
+ "disabled",
16654
+ "required",
16655
+ "readonly",
16656
+ "checked",
16657
+ "selected",
16658
+ "target",
16659
+ "rel",
16660
+ "download",
16661
+ "autocomplete",
16662
+ "autofocus",
16663
+ "min",
16664
+ "max",
16665
+ "step",
16666
+ "pattern",
16667
+ "multiple",
16668
+ "size",
16669
+ "cols",
16670
+ "rows",
16671
+ "wrap",
16672
+ "for",
16673
+ "hidden",
16674
+ "width",
16675
+ "height",
16676
+ "loading",
16677
+ "loop",
16678
+ "muted",
16679
+ "controls",
16680
+ "poster",
16681
+ "preload",
16682
+ "contenteditable",
16683
+ "draggable",
16684
+ "spellcheck",
16685
+ "translate",
16686
+ "accesskey",
16687
+ "tabindex",
16688
+ "role",
16689
+ "slot",
16690
+ "is",
16691
+ "part",
16692
+ "enterkeyhint",
16693
+ "inert",
16694
+ "inputmode",
16695
+ "sandbox",
16696
+ "allow",
16697
+ "allowfullscreen",
16698
+ "referrerpolicy",
16699
+ "fetchpriority",
16700
+ "blocking",
16701
+ "property",
16702
+ "itemprop",
16703
+ "itemtype",
16704
+ "itemid",
16705
+ "itemref",
16706
+ "itemscope",
16707
+ "charset",
16708
+ "http-equiv",
16709
+ "data",
16710
+ "nonce",
16711
+ "crossorigin",
16712
+ "integrity",
16713
+ "async",
16714
+ "defer",
16715
+ "srcset",
16716
+ "sizes",
16717
+ "media",
16718
+ "ping",
16719
+ "coords",
16720
+ "shape",
16721
+ "usemap",
16722
+ "ismap",
16723
+ "kind",
16724
+ "srclang",
16725
+ "default",
16726
+ "autoplay",
16727
+ "playsinline",
16728
+ "enctype",
16729
+ "novalidate",
16730
+ "formaction",
16731
+ "formmethod",
16732
+ "formtarget",
16733
+ "formenctype",
16734
+ "formnovalidate"
16735
+ ]);
16353
16736
  CLASS_BOUNDARY_BOOLEAN_ATTRS = /* @__PURE__ */ new Set([
16354
16737
  "disabled",
16355
16738
  "required",
@@ -24162,7 +24545,7 @@ var init_types4 = __esm({
24162
24545
  var TW_VERSION, TW_GENERATOR_META;
24163
24546
  var init_version = __esm({
24164
24547
  "../../packages/compiler/tw/codegen/version.ts"() {
24165
- TW_VERSION = "1.0.7";
24548
+ TW_VERSION = "1.0.8";
24166
24549
  TW_GENERATOR_META = `<meta name="generator" content="TW Framework ${TW_VERSION}">`;
24167
24550
  }
24168
24551
  });
@@ -24229,6 +24612,16 @@ function collectBuiltinImports(program) {
24229
24612
  for (const dir of program.directives ?? []) {
24230
24613
  if (dir.type !== "ImportDirective") continue;
24231
24614
  const imp = dir;
24615
+ if (imp.source === "tw" || imp.source === "@tw/server") {
24616
+ for (const item of imp.items ?? []) {
24617
+ const name = item.split(" as ").pop().trim();
24618
+ const spec = TW_NAMED_BUILTINS[name];
24619
+ if (spec) out.set(name, spec);
24620
+ }
24621
+ if (imp.defaultImport && TW_NAMED_BUILTINS[imp.defaultImport]) {
24622
+ out.set(imp.defaultImport, TW_NAMED_BUILTINS[imp.defaultImport]);
24623
+ }
24624
+ }
24232
24625
  if (!BUILTIN_COMPONENT_SPECIFIERS.includes(imp.source)) continue;
24233
24626
  if (imp.defaultImport) out.set(imp.defaultImport, imp.source);
24234
24627
  for (const item of imp.items ?? []) {
@@ -24298,7 +24691,7 @@ function normalizePrefetchProp(props) {
24298
24691
  return p;
24299
24692
  }
24300
24693
  function generateRouterLinkTag(props, textContent) {
24301
- const href = (props.href ?? "").trim();
24694
+ const href = (props.href ?? props.to ?? "").trim();
24302
24695
  if (!href) return `<!-- @tw/RouterLink: missing href -->`;
24303
24696
  const opts = {
24304
24697
  href,
@@ -24325,13 +24718,18 @@ function generateBuiltinTag(specifier, props, textContent) {
24325
24718
  if (specifier === "@tw/RouterLink") return generateRouterLinkTag(props, textContent);
24326
24719
  return null;
24327
24720
  }
24328
- var BUILTIN_COMPONENT_SPECIFIERS, imageConfig;
24721
+ var BUILTIN_COMPONENT_SPECIFIERS, imageConfig, TW_NAMED_BUILTINS;
24329
24722
  var init_builtin_components = __esm({
24330
24723
  "../../packages/compiler/tw/codegen/builtin-components.ts"() {
24331
24724
  init_tw2();
24332
24725
  init_tw3();
24333
24726
  BUILTIN_COMPONENT_SPECIFIERS = ["@tw/optImage", "@tw/RouterLink"];
24334
24727
  imageConfig = DEFAULT_IMAGE_CONFIG;
24728
+ TW_NAMED_BUILTINS = {
24729
+ RouterLink: "@tw/RouterLink",
24730
+ optImage: "@tw/optImage",
24731
+ Image: "@tw/optImage"
24732
+ };
24335
24733
  }
24336
24734
  });
24337
24735
 
@@ -24968,6 +25366,58 @@ function stripTssComments(src) {
24968
25366
  return out;
24969
25367
  }
24970
25368
  function compileTSS(source) {
25369
+ {
25370
+ let depth = 0;
25371
+ let line = 1;
25372
+ let inString = null;
25373
+ let inComment = false;
25374
+ let inUrl = false;
25375
+ for (let i = 0; i < source.length; i++) {
25376
+ const ch = source[i];
25377
+ const prev = i > 0 ? source[i - 1] : "";
25378
+ if (ch === "\n") {
25379
+ line++;
25380
+ inComment = false;
25381
+ continue;
25382
+ }
25383
+ if (inComment) continue;
25384
+ if (inString) {
25385
+ if (ch === inString && prev !== "\\") inString = null;
25386
+ continue;
25387
+ }
25388
+ if (inUrl) {
25389
+ if (ch === ")") inUrl = false;
25390
+ continue;
25391
+ }
25392
+ if (ch === "/" && source[i + 1] === "/" && source.slice(Math.max(0, i - 3), i).includes(":")) {
25393
+ inUrl = true;
25394
+ continue;
25395
+ }
25396
+ if (ch === "u" && source.slice(i, i + 4) === "url(") {
25397
+ inUrl = true;
25398
+ i += 3;
25399
+ continue;
25400
+ }
25401
+ if (ch === "/" && source[i + 1] === "/") {
25402
+ inComment = true;
25403
+ continue;
25404
+ }
25405
+ if (ch === '"' || ch === "'") {
25406
+ inString = ch;
25407
+ continue;
25408
+ }
25409
+ if (ch === "{") depth++;
25410
+ else if (ch === "}") {
25411
+ depth--;
25412
+ if (depth < 0) {
25413
+ throw new Error("TW303: unbalanced '}' in stylesheet (line " + line + ")\n " + (source.split("\n")[line - 1] ?? "").trim().slice(0, 80));
25414
+ }
25415
+ }
25416
+ }
25417
+ if (depth !== 0) {
25418
+ throw new Error("TW303: " + depth + " unclosed '{' in stylesheet -- a rule block is never closed\n last line: " + (source.split("\n").pop() ?? "").trim().slice(0, 80));
25419
+ }
25420
+ }
24971
25421
  const __twOut = __twCompileTSSInner(source);
24972
25422
  const leftover = __twOut.match(/\$[A-Za-z_][\w-]*/g);
24973
25423
  if (leftover && !/\$\{/.test(__twOut)) {
@@ -25512,11 +25962,11 @@ function emit(items, parent, env, mixins, out) {
25512
25962
  }
25513
25963
  function compileSCSS(source) {
25514
25964
  const cleaned0 = stripComments(source);
25515
- const UNSUPPORTED = /@(import|mixin|include|extend|use|forward|function|each|if)\b/g;
25965
+ const UNSUPPORTED = /@(import|extend|use|forward|function|each|if|else|for|while)\b/g;
25516
25966
  const m = cleaned0.match(UNSUPPORTED);
25517
25967
  if (m) {
25518
25968
  const seen = Array.from(new Set(m.map((x) => x.trim())));
25519
- console.warn("[scss] unsupported at-rule(s) ignored: " + seen.join(", ") + " -- TW's SCSS subset supports nesting, $vars and & references only");
25969
+ console.warn("[scss] unsupported at-rule(s) ignored: " + seen.join(", ") + " -- TW's SCSS subset supports nesting, $vars, & references and @mixin/@include");
25520
25970
  }
25521
25971
  const items = parseItems(stripComments(source));
25522
25972
  const out = [];
@@ -25656,13 +26106,13 @@ function collectTssImports(programs, ctx) {
25656
26106
  else ctx.inlineStyles.push(css);
25657
26107
  }
25658
26108
  } catch (e) {
25659
- if (e && typeof e.message === "string" && /^TW30[12]/.test(e.message)) throw e;
26109
+ if (e && typeof e.message === "string" && /^TW30[123]/.test(e.message)) throw e;
25660
26110
  }
25661
26111
  }
25662
26112
  }
25663
26113
  }
25664
26114
  } catch (e) {
25665
- if (e && typeof e.message === "string" && /^TW30[12]/.test(e.message)) throw e;
26115
+ if (e && typeof e.message === "string" && /^TW30[123]/.test(e.message)) throw e;
25666
26116
  }
25667
26117
  }
25668
26118
  function resolveTssPath(fromDir, rel) {
@@ -25882,6 +26332,10 @@ function generateSuspense(el, ctx) {
25882
26332
  function generateElement(el, ctx) {
25883
26333
  const tag = el.tag;
25884
26334
  const voidTag = VOID_TAGS5.has(tag.toLowerCase());
26335
+ if (tag.length > 0 && tag[0] === tag[0].toUpperCase() && !["Link", "Suspense"].includes(tag) && !componentRegistry.has(tag) && !resolveBuiltin(tag, activeBuiltinImports)) {
26336
+ console.warn("[tw] unknown component <" + tag + "> (" + el.line + ":" + el.col + ") -- no import resolves it; nothing was rendered");
26337
+ return "<!-- TW: unknown component '" + tag + "' -- check the import -->";
26338
+ }
25885
26339
  {
25886
26340
  const builtinSpec = resolveBuiltin(tag, activeBuiltinImports);
25887
26341
  if (builtinSpec) {
@@ -26141,7 +26595,13 @@ function generateComponent(comp, ctx) {
26141
26595
  ctx.slotContent = savedSlot;
26142
26596
  ctx.inHead = compSavedInHead;
26143
26597
  } else {
26144
- childrenHTML = comp.children.map((c) => generateNode(c, ctx)).join("");
26598
+ const known = ["Link", "Suspense"];
26599
+ if (!known.includes(compName)) {
26600
+ console.warn("[tw] unknown component <" + compName + "> (" + comp.line + ":" + comp.col + ") -- unresolvable import; nothing was rendered");
26601
+ childrenHTML = "<!-- TW: unknown component '" + compName + "' -- check the import -->";
26602
+ } else {
26603
+ childrenHTML = comp.children.map((c) => generateNode(c, ctx)).join("");
26604
+ }
26145
26605
  }
26146
26606
  ctx.stateVars = savedStateVars;
26147
26607
  ctx.componentStack.pop();
@@ -29938,13 +30398,27 @@ ${indentation})` : type;
29938
30398
  });
29939
30399
 
29940
30400
  // ../../packages/compiler/tw/codegen/vdom.ts
30401
+ function stateLiteralForRender(raw) {
30402
+ const evalNum = (v) => typeof v === "number" || typeof v === "boolean" ? String(v) : null;
30403
+ try {
30404
+ const n = evalNum(JSON.parse(raw));
30405
+ if (n !== null) return n;
30406
+ } catch {
30407
+ }
30408
+ try {
30409
+ const n = evalNum(new Function("return (" + raw + ")")());
30410
+ if (n !== null) return n;
30411
+ } catch {
30412
+ }
30413
+ return raw;
30414
+ }
29941
30415
  function generateWithLayout(layoutProgram, pageProgram, stateVars) {
29942
30416
  const ctx = createContext("ssr");
29943
30417
  if (stateVars) ctx.stateVars = { ...stateVars };
29944
30418
  for (const dir of pageProgram.directives || []) {
29945
30419
  if (dir.type === "StateDirective") {
29946
30420
  for (const decl of dir.declarations || []) {
29947
- ctx.stateVars[decl.name] = decl.value;
30421
+ ctx.stateVars[decl.name] = stateLiteralForRender(decl.value);
29948
30422
  }
29949
30423
  }
29950
30424
  }
@@ -29976,7 +30450,7 @@ function generateWithLayoutChain(layoutPrograms, pageProgram, stateVars) {
29976
30450
  for (const dir of pageProgram.directives || []) {
29977
30451
  if (dir.type === "StateDirective") {
29978
30452
  for (const decl of dir.declarations || []) {
29979
- ctx2.stateVars[decl.name] = decl.value;
30453
+ ctx2.stateVars[decl.name] = stateLiteralForRender(decl.value);
29980
30454
  }
29981
30455
  }
29982
30456
  }
@@ -30167,7 +30641,9 @@ function generate(program, stateVars) {
30167
30641
  ctx.hasInteractivity = true;
30168
30642
  continue;
30169
30643
  }
30170
- ctx.stateVars[decl.name] = decl.value;
30644
+ if (!(decl.name in ctx.stateVars)) {
30645
+ ctx.stateVars[decl.name] = stateLiteralForRender(decl.value);
30646
+ }
30171
30647
  let v = decl.value;
30172
30648
  try {
30173
30649
  v = JSON.parse(decl.value);
@@ -30500,7 +30976,11 @@ var init_codes = __esm({
30500
30976
  TW090: { message: "cache { } requires a revalidate window or a life profile", severity: "error", category: "syntax" },
30501
30977
  TW091: { message: "fn cached handler is impure (request.cookies/headers/body or setSignal)", severity: "error", category: "semantic" },
30502
30978
  TW092: { message: "Unknown cache profile", severity: "error", category: "semantic" },
30503
- TW093: { message: "Non-deterministic call in a cached handler (value freezes into the cache entry)", severity: "warning", category: "best-practice" }
30979
+ TW093: { message: "Non-deterministic call in a cached handler (value freezes into the cache entry)", severity: "warning", category: "best-practice" },
30980
+ TW094: { message: "Invalid state literal -- array/object elements must be comma-separated (and objects need colons)", severity: "error", category: "syntax" },
30981
+ TW095: { message: "Invalid for-loop iterable -- use {varName} or a valid comma-separated literal", severity: "error", category: "syntax" },
30982
+ TW096: { message: "Unknown state variable in interpolation (renders empty)", severity: "warning", category: "semantic" },
30983
+ TW097: { message: "while with a comparison renders its body ONCE at build time (state cannot mutate during SSR) -- use for loops for repetition", severity: "warning", category: "semantic" }
30504
30984
  };
30505
30985
  }
30506
30986
  });
@@ -30821,6 +31301,124 @@ function checkUnusedDeclarations(program, filePath, diags) {
30821
31301
  }
30822
31302
  }
30823
31303
  }
31304
+ function literalIsBroken(value) {
31305
+ const v = value.trim();
31306
+ if (!v.startsWith("[") && !v.startsWith("{")) return false;
31307
+ try {
31308
+ new Function("return (" + v + ")");
31309
+ return false;
31310
+ } catch {
31311
+ return true;
31312
+ }
31313
+ }
31314
+ function checkStateLiterals(program, filePath, diags) {
31315
+ for (const decl of collectStateDecls(program)) {
31316
+ if (decl.signalKind === "derived") continue;
31317
+ if (literalIsBroken(String(decl.value))) {
31318
+ diags.push(createDiagnostic(
31319
+ "TW094",
31320
+ decl.line,
31321
+ decl.col,
31322
+ filePath,
31323
+ `${decl.name} = ${decl.value}`,
31324
+ [
31325
+ `Comma-separate array elements: ${decl.name} = ["a", "b", "c"]`,
31326
+ `Objects need colons: ${decl.name} = { key: "value" }`
31327
+ ]
31328
+ ));
31329
+ }
31330
+ }
31331
+ }
31332
+ function checkLoopIterables(program, filePath, diags) {
31333
+ forEachNode(program, (node) => {
31334
+ if (!node || node.type !== "For") return;
31335
+ const iterable = String(node.iterable ?? "").trim();
31336
+ if (!iterable) {
31337
+ diags.push(createDiagnostic(
31338
+ "TW095",
31339
+ node.line,
31340
+ node.col,
31341
+ filePath,
31342
+ "for-loop has no iterable",
31343
+ [`Use: for item in {list} { ... }`]
31344
+ ));
31345
+ return;
31346
+ }
31347
+ if (/^\{[A-Za-z_][\w.]*\}$/.test(iterable)) return;
31348
+ if (/^[A-Za-z_][\w.]*$/.test(iterable)) return;
31349
+ if (literalIsBroken(iterable)) {
31350
+ diags.push(createDiagnostic(
31351
+ "TW095",
31352
+ node.line,
31353
+ node.col,
31354
+ filePath,
31355
+ "for x in " + iterable,
31356
+ [
31357
+ `Wrap a state variable in braces: for item in {items}`,
31358
+ `Literals need commas: for item in {["a", "b", "c"]}`
31359
+ ]
31360
+ ));
31361
+ }
31362
+ });
31363
+ }
31364
+ function checkUnknownStateVars(program, filePath, diags) {
31365
+ const known = /* @__PURE__ */ new Set();
31366
+ for (const decl of collectStateDecls(program)) known.add(decl.name);
31367
+ forEachNode(program, (node) => {
31368
+ if (node && node.type === "For" && node.varName) known.add(node.varName);
31369
+ if (node && node.type === "For" && node.indexName) known.add(node.indexName);
31370
+ });
31371
+ const BUILTINS2 = /* @__PURE__ */ new Set([
31372
+ "params",
31373
+ "props",
31374
+ "Math",
31375
+ "Date",
31376
+ "JSON",
31377
+ "String",
31378
+ "Number",
31379
+ "Boolean",
31380
+ "Array",
31381
+ "Object",
31382
+ "window",
31383
+ "true",
31384
+ "false",
31385
+ "null",
31386
+ "undefined"
31387
+ ]);
31388
+ forEachNode(program, (node) => {
31389
+ if (!isText(node)) return;
31390
+ const text = String(node.value ?? "");
31391
+ if (!text.includes("{")) return;
31392
+ for (const m of text.matchAll(/\{\s*([A-Za-z_][\w]*)\s*\}/g)) {
31393
+ const name = m[1];
31394
+ if (known.has(name) || BUILTINS2.has(name)) continue;
31395
+ diags.push(createDiagnostic(
31396
+ "TW096",
31397
+ node.line,
31398
+ node.col,
31399
+ filePath,
31400
+ `{${name}}`,
31401
+ [`Declare it in the state block, or check the spelling`]
31402
+ ));
31403
+ }
31404
+ });
31405
+ }
31406
+ function checkWhileComparisons(program, filePath, diags) {
31407
+ forEachNode(program, (node) => {
31408
+ if (!node || node.type !== "While") return;
31409
+ const cond = String(node.condition ?? "");
31410
+ if (/[<>=!]/.test(cond)) {
31411
+ diags.push(createDiagnostic(
31412
+ "TW097",
31413
+ node.line,
31414
+ node.col,
31415
+ filePath,
31416
+ "while " + cond,
31417
+ ["Use `for item in {list}` for repetition", "while is for boolean flags (state { loading = true })"]
31418
+ ));
31419
+ }
31420
+ });
31421
+ }
30824
31422
  var HTML_ELEMENTS2, HTML_ATTRIBUTES2, GLOBAL_ATTRIBUTES2, CSS_PROPERTIES2, CSS_ALIASES2, EVENT_TYPES2, ARIA_ROLES2, ARIA_ATTRIBUTES2;
30825
31423
  var init_rules = __esm({
30826
31424
  "../../packages/compiler/tw/diagnostics/rules.ts"() {
@@ -31711,6 +32309,10 @@ function diagnose(program, filePath) {
31711
32309
  checkSemanticErrors(program, filePath, diagnostics);
31712
32310
  checkDirectives(program, filePath, diagnostics);
31713
32311
  checkUnusedDeclarations(program, filePath, diagnostics);
32312
+ checkStateLiterals(program, filePath, diagnostics);
32313
+ checkLoopIterables(program, filePath, diagnostics);
32314
+ checkUnknownStateVars(program, filePath, diagnostics);
32315
+ checkWhileComparisons(program, filePath, diagnostics);
31714
32316
  return diagnostics;
31715
32317
  }
31716
32318
  var RuleEngine;
@@ -41625,6 +42227,7 @@ var init_render_pipeline = __esm({
41625
42227
  init_twm_loader();
41626
42228
  init_scanner();
41627
42229
  init_layout_chain();
42230
+ init_signal_stream();
41628
42231
  init_helpers();
41629
42232
  init_tw6();
41630
42233
  init_twm_loader();
@@ -41778,6 +42381,17 @@ var init_render_pipeline = __esm({
41778
42381
  try {
41779
42382
  const pageState = { ...stateVars ?? {}, ...match.params ?? {}, params: match.params ?? {} };
41780
42383
  const requestVarKeys = new Set(Object.keys({ ...stateVars ?? {}, ...match.params ?? {} }));
42384
+ try {
42385
+ const hub2 = getSignalHub();
42386
+ const snap = hub2.snapshot();
42387
+ if (snap && snap.snapshot && typeof snap.snapshot === "object") {
42388
+ for (const [k, v] of Object.entries(snap.snapshot)) {
42389
+ pageState[k] = v;
42390
+ requestVarKeys.add(k);
42391
+ }
42392
+ }
42393
+ } catch {
42394
+ }
41781
42395
  const pageCompiled = this.compileFile(ctx.page, pageState);
41782
42396
  {
41783
42397
  const seed = pageCompiled.stateSeed;
@@ -41823,24 +42437,30 @@ var init_render_pipeline = __esm({
41823
42437
  let layoutTitle;
41824
42438
  const liftedParts = [];
41825
42439
  {
41826
- for (const headMatch of bodyHTML.matchAll(/<head[^>]*>([\s\S]*?)<\/head>/gi)) {
42440
+ const collectedTitles = [];
42441
+ for (const headMatch of bodyHTML.matchAll(/<head(?:\s[^>]*)?>([\s\S]*?)<\/head\s*>/gi)) {
41827
42442
  const headInner = headMatch[1];
41828
42443
  const tMatch = headInner.match(/<title>([^<]*)<\/title>/i);
41829
- if (tMatch) layoutTitle = tMatch[1].trim().replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
41830
- const kids = headInner.replace(/<title>[^<]*<\/title>/gi, "").replace(/<meta[^>]+charset[^>]*>/gi, "").replace(/<meta[^>]+viewport[^>]*>/gi, "").trim();
42444
+ if (tMatch) collectedTitles.push(tMatch[1].trim().replace(/&(amp|lt|gt|quot);/g, (m, k) => k === "amp" ? String.fromCharCode(38) : k === "lt" ? String.fromCharCode(60) : k === "gt" ? String.fromCharCode(62) : String.fromCharCode(34)));
42445
+ else collectedTitles.push("");
42446
+ const kids = headInner.replace(/<title>[^<]*<\/title>/gi, "").replace(/<meta[^>]+charset[^>]*>/gi, "").replace(/<meta[^>]+viewport[^>]*>/gi, "").replace(/<meta[^>]+generator[^>]*>/gi, "").trim();
41831
42447
  if (kids) liftedParts.push(kids);
41832
42448
  }
41833
- bodyHTML = bodyHTML.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, "");
42449
+ bodyHTML = bodyHTML.replace(/<head(?:\s[^>]*)?>[\s\S]*?<\/head\s*>/gi, "");
42450
+ const templateTitle = collectedTitles.find((t2) => t2.includes("__TW_PAGETITLE__"));
42451
+ layoutTitle = templateTitle ?? collectedTitles.filter(Boolean).pop();
41834
42452
  bodyHTML = bodyHTML.replace(/<\/?(?:html|body)[^>]*>/gi, "");
41835
42453
  bodyHTML = bodyHTML.replace(/<!DOCTYPE[^>]*>/gi, "");
41836
42454
  }
41837
42455
  const liftedHead = liftedParts.join("\n");
41838
- if (ctx.error) {
42456
+ const liveRenderMode = this.extractRenderMode(ctx.page);
42457
+ if (ctx.error && (liveRenderMode === "ssr" || liveRenderMode === "stream")) {
41839
42458
  const errorCompiled = this.compileFile(ctx.error);
41840
42459
  allCSS = errorCompiled.css + "\n" + allCSS;
41841
42460
  bodyHTML = `<div data-error-boundary>${bodyHTML}</div>`;
41842
42461
  }
41843
- if (ctx.loading) {
42462
+ const needsSkeleton = bodyHTML.includes("data-tw-suspense") || bodyHTML.includes("data-tw-ppr");
42463
+ if (ctx.loading && needsSkeleton) {
41844
42464
  const loadingCompiled = this.compileFile(ctx.loading);
41845
42465
  allCSS = loadingCompiled.css + "\n" + allCSS;
41846
42466
  let lh = String(loadingCompiled.html || "");
@@ -41852,7 +42472,7 @@ ${bodyHTML}`;
41852
42472
  const pageTitle = this.extractTitle(ctx.page) || "TW Page";
41853
42473
  let title = pageTitle;
41854
42474
  if (layoutTitle !== void 0) {
41855
- title = layoutTitle.replace(/\{\s*page\.title\s*\}/g, pageTitle);
42475
+ title = layoutTitle.replace(/__TW_PAGETITLE__/g, pageTitle).replace(/\{\s*page\.title\s*\}/g, pageTitle);
41856
42476
  if (!title.trim()) title = pageTitle;
41857
42477
  }
41858
42478
  const metaTags = this.buildMetaTags(ctx.page);
@@ -41872,7 +42492,9 @@ ${bodyHTML}`;
41872
42492
  } catch {
41873
42493
  }
41874
42494
  let fullHTML = this.assembleHTML(bodyHTML, allCSS, allJS, title, metaTags + headExtra);
41875
- if (!/__tw_runtime\.js/.test(fullHTML)) {
42495
+ const pageRenderMode = this.extractRenderMode(ctx.page);
42496
+ const needsClientRuntime = /data-tw-event|data-tw-i|__TW_SIGNALS__|data-tw-suspense|data-tw-ppr/.test(fullHTML) || pageRenderMode === "csr" || pageRenderMode === "stream" || pageRenderMode === "ppr" || pageRenderMode === "signalStream";
42497
+ if (needsClientRuntime && !/__tw_runtime\.js/.test(fullHTML)) {
41876
42498
  let stateSeed = "{}";
41877
42499
  try {
41878
42500
  stateSeed = JSON.stringify(pageState ?? {});
@@ -41991,7 +42613,8 @@ ${bodyHTML}`;
41991
42613
  throw new Error(`Route file not found: ${file.absolutePath}`);
41992
42614
  }
41993
42615
  const source = (0, import_node_fs6.readFileSync)(file.absolutePath, "utf-8");
41994
- const result = compileSync(source, {
42616
+ const srcWithSentinel = source.replace(/\{\s*page\.title\s*\}/g, "__TW_PAGETITLE__");
42617
+ const result = compileSync(srcWithSentinel, {
41995
42618
  filePath: file.absolutePath,
41996
42619
  optimize: true,
41997
42620
  diagnostics: false,
@@ -42065,12 +42688,13 @@ ${bodyHTML}`;
42065
42688
  try {
42066
42689
  const source = (0, import_node_fs6.readFileSync)(file.absolutePath, "utf-8");
42067
42690
  const masked = maskSourceStringsAndComments(source);
42068
- const m = masked.match(/page\s*\{[^}]*title\s+"/);
42691
+ const m = masked.match(/page\s*\{[^}]*title\s/);
42069
42692
  if (!m || m.index === void 0) return null;
42070
42693
  const start = m.index + m[0].length;
42071
- const end = source.indexOf('"', start);
42072
- if (end === -1 || end === start) return null;
42073
- return source.slice(start, end).replace(/\\([{}"])/g, "$1");
42694
+ if (source[start] !== '"') return null;
42695
+ const end = source.indexOf('"', start + 1);
42696
+ if (end === -1 || end === start + 1) return null;
42697
+ return source.slice(start + 1, end).replace(/\\([{}"])/g, "$1");
42074
42698
  } catch {
42075
42699
  return null;
42076
42700
  }
@@ -42119,7 +42743,8 @@ ${bodyHTML}`;
42119
42743
  * render-cache never reads `stale`.
42120
42744
  */
42121
42745
  stampCacheHeaders(headers, entry, now, verdict) {
42122
- const out = { ...headers ?? {}, "x-tw-cache": verdict };
42746
+ const out = { ...headers ?? {} };
42747
+ if (process.env.TW_DEBUG_CACHE || process.env.NODE_ENV === "test") out["x-tw-cache"] = verdict;
42123
42748
  const c = entry?.cache;
42124
42749
  if (c) {
42125
42750
  const ageSec = entry?.createdAt ? Math.max(0, Math.floor((now - entry.createdAt) / 1e3)) : 0;
@@ -43653,7 +44278,6 @@ function securityHeadersMiddleware() {
43653
44278
  return (req, res, next) => {
43654
44279
  res.headers.set("X-Content-Type-Options", "nosniff");
43655
44280
  res.headers.set("X-Frame-Options", "SAMEORIGIN");
43656
- res.headers.set("X-XSS-Protection", "1; mode=block");
43657
44281
  res.headers.set("X-Permitted-Cross-Domain-Policies", "none");
43658
44282
  res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
43659
44283
  res.headers.set("Cross-Origin-Opener-Policy", "same-origin");
@@ -43830,13 +44454,15 @@ function compressionMiddleware() {
43830
44454
  await next();
43831
44455
  };
43832
44456
  }
43833
- function securityHeadersMiddleware2() {
44457
+ function securityHeadersMiddleware2(opts) {
43834
44458
  return async (ctx, next) => {
43835
44459
  ctx.headers["x-content-type-options"] = "nosniff";
43836
44460
  ctx.headers["x-frame-options"] = "DENY";
43837
- ctx.headers["x-xss-protection"] = "1; mode=block";
43838
44461
  ctx.headers["referrer-policy"] = "strict-origin-when-cross-origin";
43839
44462
  ctx.headers["permissions-policy"] = "camera=(), microphone=(), geolocation=()";
44463
+ if (opts?.csp !== false && !ctx.headers["content-security-policy"]) {
44464
+ ctx.headers["content-security-policy"] = typeof opts?.csp === "string" ? opts.csp : "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'";
44465
+ }
43840
44466
  await next();
43841
44467
  };
43842
44468
  }
@@ -43919,6 +44545,161 @@ var init_middleware2 = __esm({
43919
44545
  }
43920
44546
  });
43921
44547
 
44548
+ // ../../packages/server/tw/compression.ts
44549
+ function shouldCompress(contentType, options = {}) {
44550
+ const opts = { ...DEFAULT_OPTIONS4, ...options };
44551
+ const ct = contentType.split(";")[0].trim();
44552
+ return opts.contentTypes.includes(ct);
44553
+ }
44554
+ function isLargeEnough(size, options = {}) {
44555
+ const opts = { ...DEFAULT_OPTIONS4, ...options };
44556
+ return size >= opts.threshold;
44557
+ }
44558
+ async function compress(data, encoding = "gzip", options = {}) {
44559
+ const opts = { ...DEFAULT_OPTIONS4, ...options };
44560
+ const input = typeof data === "string" ? new TextEncoder().encode(data) : data;
44561
+ if (input.length < opts.threshold) {
44562
+ return input;
44563
+ }
44564
+ try {
44565
+ if (typeof Bun !== "undefined" && Bun.gzipSync) {
44566
+ if (encoding === "gzip") return Bun.gzipSync(input, { level: opts.level });
44567
+ if (encoding === "deflate" && Bun.deflateSync) return Bun.deflateSync(input, { level: opts.level });
44568
+ }
44569
+ } catch {
44570
+ }
44571
+ try {
44572
+ const zlib = await import("node:zlib");
44573
+ if (encoding === "gzip") {
44574
+ return zlib.gzipSync(input, { level: opts.level });
44575
+ }
44576
+ if (encoding === "deflate") {
44577
+ return zlib.deflateSync(input, { level: opts.level });
44578
+ }
44579
+ if (encoding === "brotli" && zlib.brotliCompressSync) {
44580
+ return zlib.brotliCompressSync(input, {
44581
+ params: {
44582
+ [zlib.constants.BROTLI_PARAM_QUALITY]: opts.level
44583
+ }
44584
+ });
44585
+ }
44586
+ } catch {
44587
+ }
44588
+ return input;
44589
+ }
44590
+ async function decompress(data, encoding = "gzip") {
44591
+ try {
44592
+ if (typeof Bun !== "undefined" && Bun.gunzipSync) {
44593
+ if (encoding === "gzip") return Bun.gunzipSync(data);
44594
+ if (encoding === "deflate" && Bun.inflateSync) return Bun.inflateSync(data);
44595
+ }
44596
+ } catch {
44597
+ }
44598
+ try {
44599
+ const zlib = await import("node:zlib");
44600
+ if (encoding === "gzip") return zlib.gunzipSync(data);
44601
+ if (encoding === "deflate") return zlib.inflateSync(data);
44602
+ if (encoding === "brotli" && zlib.brotliDecompressSync) return zlib.brotliDecompressSync(data);
44603
+ } catch {
44604
+ }
44605
+ return data;
44606
+ }
44607
+ function negotiateEncoding(acceptEncoding) {
44608
+ const encodings = acceptEncoding.split(",").map((e) => e.trim().split(";")[0].trim()).filter(Boolean);
44609
+ if (encodings.includes("br")) return "brotli";
44610
+ if (encodings.includes("gzip")) return "gzip";
44611
+ if (encodings.includes("deflate")) return "deflate";
44612
+ return null;
44613
+ }
44614
+ function createCompressionHeaders(encoding, originalSize, compressedSize) {
44615
+ return {
44616
+ "Content-Encoding": encoding === "brotli" ? "br" : encoding,
44617
+ "Vary": "Accept-Encoding",
44618
+ "X-Original-Size": String(originalSize),
44619
+ "X-Compressed-Size": String(compressedSize),
44620
+ "X-Compression-Ratio": originalSize > 0 ? `${((1 - compressedSize / originalSize) * 100).toFixed(1)}%` : "0%"
44621
+ };
44622
+ }
44623
+ async function compressResponse(body, contentType, acceptEncoding, options = {}) {
44624
+ const input = typeof body === "string" ? new TextEncoder().encode(body) : body;
44625
+ const opts = { ...DEFAULT_OPTIONS4, ...options };
44626
+ if (!shouldCompress(contentType, opts) || !isLargeEnough(input.length, opts)) {
44627
+ return { body: input, headers: {}, compressed: false };
44628
+ }
44629
+ const encoding = negotiateEncoding(acceptEncoding);
44630
+ if (!encoding) {
44631
+ return { body: input, headers: {}, compressed: false };
44632
+ }
44633
+ try {
44634
+ const compressed = await compress(input, encoding, opts);
44635
+ const headers = createCompressionHeaders(encoding, input.length, compressed.length);
44636
+ return { body: compressed, headers, compressed: true };
44637
+ } catch {
44638
+ return { body: input, headers: {}, compressed: false };
44639
+ }
44640
+ }
44641
+ var DEFAULT_OPTIONS4, StreamingCompressor;
44642
+ var init_compression = __esm({
44643
+ "../../packages/server/tw/compression.ts"() {
44644
+ DEFAULT_OPTIONS4 = {
44645
+ level: 6,
44646
+ threshold: 1024,
44647
+ // Don't compress < 1KB
44648
+ brotli: true,
44649
+ contentTypes: [
44650
+ "text/html",
44651
+ "text/css",
44652
+ "text/plain",
44653
+ "text/javascript",
44654
+ "application/javascript",
44655
+ "application/json",
44656
+ "application/xml",
44657
+ "application/xhtml+xml",
44658
+ "image/svg+xml",
44659
+ "font/woff",
44660
+ "font/woff2",
44661
+ "application/wasm"
44662
+ ]
44663
+ };
44664
+ StreamingCompressor = class {
44665
+ chunks = [];
44666
+ encoding;
44667
+ options;
44668
+ constructor(encoding = "gzip", options = {}) {
44669
+ this.encoding = encoding;
44670
+ this.options = { ...DEFAULT_OPTIONS4, ...options };
44671
+ }
44672
+ /**
44673
+ * Add a chunk of data to be compressed.
44674
+ */
44675
+ push(data) {
44676
+ const input = typeof data === "string" ? new TextEncoder().encode(data) : data;
44677
+ this.chunks.push(input);
44678
+ }
44679
+ /**
44680
+ * Flush all data and return compressed result.
44681
+ */
44682
+ async flush() {
44683
+ const totalLength = this.chunks.reduce((sum, c) => sum + c.length, 0);
44684
+ const combined = new Uint8Array(totalLength);
44685
+ let offset = 0;
44686
+ for (const chunk of this.chunks) {
44687
+ combined.set(chunk, offset);
44688
+ offset += chunk.length;
44689
+ }
44690
+ this.chunks = [];
44691
+ return compress(combined, this.encoding, this.options);
44692
+ }
44693
+ /**
44694
+ * Get the total uncompressed size so far.
44695
+ */
44696
+ get uncompressedSize() {
44697
+ return this.chunks.reduce((sum, c) => sum + c.length, 0);
44698
+ }
44699
+ };
44700
+ }
44701
+ });
44702
+
43922
44703
  // ../../packages/server/tw/static/index.ts
43923
44704
  var import_node_fs8, import_node_path15, StaticHandler;
43924
44705
  var init_static = __esm({
@@ -44128,7 +44909,7 @@ var init_policy = __esm({
44128
44909
  "Content-Security-Policy": this.build(),
44129
44910
  "X-Content-Type-Options": "nosniff",
44130
44911
  "X-Frame-Options": "DENY",
44131
- "X-XSS-Protection": "1; mode=block",
44912
+ // BUG 48: x-xss-protection removed (deprecated, harmful in old browsers)
44132
44913
  "Referrer-Policy": "strict-origin-when-cross-origin",
44133
44914
  "Permissions-Policy": "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()",
44134
44915
  "Cross-Origin-Opener-Policy": "same-origin",
@@ -48762,9 +49543,10 @@ function devSecurityHeaders() {
48762
49543
  removePoweredBy: true
48763
49544
  });
48764
49545
  }
48765
- var DEFAULTS2, SecurityHeaders2;
49546
+ var BASELINE_CSP, DEFAULTS2, SecurityHeaders2;
48766
49547
  var init_security_headers = __esm({
48767
49548
  "../../packages/security/tw/headers/security-headers.ts"() {
49549
+ BASELINE_CSP = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'";
48768
49550
  DEFAULTS2 = {
48769
49551
  hstsMaxAge: 31536e3,
48770
49552
  // 1 year
@@ -48773,7 +49555,9 @@ var init_security_headers = __esm({
48773
49555
  frameOptions: "SAMEORIGIN",
48774
49556
  contentTypeOptions: true,
48775
49557
  referrerPolicy: "strict-origin-when-cross-origin",
48776
- xssProtection: "1; mode=block",
49558
+ // v1.0.8 round 5 (BUG 48): x-xss-protection deprecated since 2019 --
49559
+ // OFF by default now; opt back in with security.xssProtection.
49560
+ xssProtection: void 0,
48777
49561
  removePoweredBy: true,
48778
49562
  coep: "unsafe-none",
48779
49563
  coop: "same-origin",
@@ -48829,6 +49613,9 @@ var init_security_headers = __esm({
48829
49613
  if (this.config.serverHeader) {
48830
49614
  headers["Server"] = this.config.serverHeader;
48831
49615
  }
49616
+ if (this.config.csp !== false && !headers["Content-Security-Policy"]) {
49617
+ headers["Content-Security-Policy"] = typeof this.config.csp === "string" ? this.config.csp : BASELINE_CSP;
49618
+ }
48832
49619
  return headers;
48833
49620
  }
48834
49621
  /** Returns the security headers object. */
@@ -53039,161 +53826,6 @@ var init_ssr = __esm({
53039
53826
  }
53040
53827
  });
53041
53828
 
53042
- // ../../packages/server/tw/compression.ts
53043
- function shouldCompress(contentType, options = {}) {
53044
- const opts = { ...DEFAULT_OPTIONS4, ...options };
53045
- const ct = contentType.split(";")[0].trim();
53046
- return opts.contentTypes.includes(ct);
53047
- }
53048
- function isLargeEnough(size, options = {}) {
53049
- const opts = { ...DEFAULT_OPTIONS4, ...options };
53050
- return size >= opts.threshold;
53051
- }
53052
- async function compress(data, encoding = "gzip", options = {}) {
53053
- const opts = { ...DEFAULT_OPTIONS4, ...options };
53054
- const input = typeof data === "string" ? new TextEncoder().encode(data) : data;
53055
- if (input.length < opts.threshold) {
53056
- return input;
53057
- }
53058
- try {
53059
- if (typeof Bun !== "undefined" && Bun.gzipSync) {
53060
- if (encoding === "gzip") return Bun.gzipSync(input, { level: opts.level });
53061
- if (encoding === "deflate" && Bun.deflateSync) return Bun.deflateSync(input, { level: opts.level });
53062
- }
53063
- } catch {
53064
- }
53065
- try {
53066
- const zlib = await import("node:zlib");
53067
- if (encoding === "gzip") {
53068
- return zlib.gzipSync(input, { level: opts.level });
53069
- }
53070
- if (encoding === "deflate") {
53071
- return zlib.deflateSync(input, { level: opts.level });
53072
- }
53073
- if (encoding === "brotli" && zlib.brotliCompressSync) {
53074
- return zlib.brotliCompressSync(input, {
53075
- params: {
53076
- [zlib.constants.BROTLI_PARAM_QUALITY]: opts.level
53077
- }
53078
- });
53079
- }
53080
- } catch {
53081
- }
53082
- return input;
53083
- }
53084
- async function decompress(data, encoding = "gzip") {
53085
- try {
53086
- if (typeof Bun !== "undefined" && Bun.gunzipSync) {
53087
- if (encoding === "gzip") return Bun.gunzipSync(data);
53088
- if (encoding === "deflate" && Bun.inflateSync) return Bun.inflateSync(data);
53089
- }
53090
- } catch {
53091
- }
53092
- try {
53093
- const zlib = await import("node:zlib");
53094
- if (encoding === "gzip") return zlib.gunzipSync(data);
53095
- if (encoding === "deflate") return zlib.inflateSync(data);
53096
- if (encoding === "brotli" && zlib.brotliDecompressSync) return zlib.brotliDecompressSync(data);
53097
- } catch {
53098
- }
53099
- return data;
53100
- }
53101
- function negotiateEncoding(acceptEncoding) {
53102
- const encodings = acceptEncoding.split(",").map((e) => e.trim().split(";")[0].trim()).filter(Boolean);
53103
- if (encodings.includes("br")) return "brotli";
53104
- if (encodings.includes("gzip")) return "gzip";
53105
- if (encodings.includes("deflate")) return "deflate";
53106
- return null;
53107
- }
53108
- function createCompressionHeaders(encoding, originalSize, compressedSize) {
53109
- return {
53110
- "Content-Encoding": encoding === "brotli" ? "br" : encoding,
53111
- "Vary": "Accept-Encoding",
53112
- "X-Original-Size": String(originalSize),
53113
- "X-Compressed-Size": String(compressedSize),
53114
- "X-Compression-Ratio": originalSize > 0 ? `${((1 - compressedSize / originalSize) * 100).toFixed(1)}%` : "0%"
53115
- };
53116
- }
53117
- async function compressResponse(body, contentType, acceptEncoding, options = {}) {
53118
- const input = typeof body === "string" ? new TextEncoder().encode(body) : body;
53119
- const opts = { ...DEFAULT_OPTIONS4, ...options };
53120
- if (!shouldCompress(contentType, opts) || !isLargeEnough(input.length, opts)) {
53121
- return { body: input, headers: {}, compressed: false };
53122
- }
53123
- const encoding = negotiateEncoding(acceptEncoding);
53124
- if (!encoding) {
53125
- return { body: input, headers: {}, compressed: false };
53126
- }
53127
- try {
53128
- const compressed = await compress(input, encoding, opts);
53129
- const headers = createCompressionHeaders(encoding, input.length, compressed.length);
53130
- return { body: compressed, headers, compressed: true };
53131
- } catch {
53132
- return { body: input, headers: {}, compressed: false };
53133
- }
53134
- }
53135
- var DEFAULT_OPTIONS4, StreamingCompressor;
53136
- var init_compression = __esm({
53137
- "../../packages/server/tw/compression.ts"() {
53138
- DEFAULT_OPTIONS4 = {
53139
- level: 6,
53140
- threshold: 1024,
53141
- // Don't compress < 1KB
53142
- brotli: true,
53143
- contentTypes: [
53144
- "text/html",
53145
- "text/css",
53146
- "text/plain",
53147
- "text/javascript",
53148
- "application/javascript",
53149
- "application/json",
53150
- "application/xml",
53151
- "application/xhtml+xml",
53152
- "image/svg+xml",
53153
- "font/woff",
53154
- "font/woff2",
53155
- "application/wasm"
53156
- ]
53157
- };
53158
- StreamingCompressor = class {
53159
- chunks = [];
53160
- encoding;
53161
- options;
53162
- constructor(encoding = "gzip", options = {}) {
53163
- this.encoding = encoding;
53164
- this.options = { ...DEFAULT_OPTIONS4, ...options };
53165
- }
53166
- /**
53167
- * Add a chunk of data to be compressed.
53168
- */
53169
- push(data) {
53170
- const input = typeof data === "string" ? new TextEncoder().encode(data) : data;
53171
- this.chunks.push(input);
53172
- }
53173
- /**
53174
- * Flush all data and return compressed result.
53175
- */
53176
- async flush() {
53177
- const totalLength = this.chunks.reduce((sum, c) => sum + c.length, 0);
53178
- const combined = new Uint8Array(totalLength);
53179
- let offset = 0;
53180
- for (const chunk of this.chunks) {
53181
- combined.set(chunk, offset);
53182
- offset += chunk.length;
53183
- }
53184
- this.chunks = [];
53185
- return compress(combined, this.encoding, this.options);
53186
- }
53187
- /**
53188
- * Get the total uncompressed size so far.
53189
- */
53190
- get uncompressedSize() {
53191
- return this.chunks.reduce((sum, c) => sum + c.length, 0);
53192
- }
53193
- };
53194
- }
53195
- });
53196
-
53197
53829
  // ../../packages/server/tw/edge.ts
53198
53830
  function createEdgeHandler(config) {
53199
53831
  const cache = /* @__PURE__ */ new Map();
@@ -53912,8 +54544,11 @@ var init_tw6 = __esm({
53912
54544
  init_routing2();
53913
54545
  import_node_path17 = require("node:path");
53914
54546
  import_node_fs11 = require("node:fs");
54547
+ init_twm_rules();
53915
54548
  init_router();
53916
54549
  init_middleware2();
54550
+ init_compression();
54551
+ init_signal_stream();
53917
54552
  init_static();
53918
54553
  init_tw5();
53919
54554
  init_ssr();
@@ -53962,6 +54597,7 @@ var init_tw6 = __esm({
53962
54597
  }
53963
54598
  ssr = null;
53964
54599
  server = null;
54600
+ compressionMode = "off";
53965
54601
  isRunning = false;
53966
54602
  pluginManager = null;
53967
54603
  imageHandler = null;
@@ -53978,6 +54614,40 @@ var init_tw6 = __esm({
53978
54614
  const entries = Object.entries(cfgIn.redirects);
53979
54615
  cfgIn.redirects = entries.map(([from, to]) => typeof to === "string" ? { from, to } : { from, ...to });
53980
54616
  }
54617
+ if (Array.isArray(cfgIn.redirects)) {
54618
+ const REDIRECT_KNOWN = /* @__PURE__ */ new Set(["from", "to", "status", "source", "destination", "permanent"]);
54619
+ let redirectDropped = false;
54620
+ const normalizedRedirects = cfgIn.redirects.map((r) => {
54621
+ if (!r || typeof r !== "object") return r;
54622
+ let touched = false;
54623
+ const mapped = { ...r };
54624
+ if (mapped.from === void 0 && mapped.source !== void 0) {
54625
+ mapped.from = mapped.source;
54626
+ touched = true;
54627
+ }
54628
+ if (mapped.to === void 0 && mapped.destination !== void 0) {
54629
+ mapped.to = mapped.destination;
54630
+ touched = true;
54631
+ }
54632
+ if (mapped.status === void 0 && mapped.permanent !== void 0) {
54633
+ mapped.status = mapped.permanent ? 301 : 302;
54634
+ touched = true;
54635
+ }
54636
+ const unknown = Object.keys(mapped).filter((k) => !REDIRECT_KNOWN.has(k));
54637
+ if (unknown.length > 0) {
54638
+ console.warn("[tw] redirects: unrecognized field" + (unknown.length > 1 ? "s" : "") + " " + unknown.join(", ") + " in entry { from: " + JSON.stringify(mapped.from) + " } -- accepted fields: from, to, status");
54639
+ }
54640
+ if (typeof mapped.from !== "string" || typeof mapped.to !== "string") {
54641
+ console.warn("[tw] redirects: entry missing from/to after normalization -- skipped (" + JSON.stringify(r).slice(0, 80) + ")");
54642
+ redirectDropped = true;
54643
+ return null;
54644
+ }
54645
+ return touched ? mapped : r;
54646
+ }).filter((x) => x !== null || (redirectDropped = true));
54647
+ if (redirectDropped || normalizedRedirects.some((x, i) => x !== cfgIn.redirects[i])) {
54648
+ cfgIn.redirects = normalizedRedirects.filter(Boolean);
54649
+ }
54650
+ }
53981
54651
  if (cfgIn.headers && !Array.isArray(cfgIn.headers)) {
53982
54652
  const entries = Object.entries(cfgIn.headers);
53983
54653
  cfgIn.headers = entries.map(([source, headers]) => ({ source, headers }));
@@ -53995,6 +54665,12 @@ var init_tw6 = __esm({
53995
54665
  }
53996
54666
  }
53997
54667
  this.pluginManager = this.options.plugins ?? null;
54668
+ {
54669
+ const raw = this.options.compression ?? true;
54670
+ if (raw === false || raw === "none" || raw === "off" || raw === "false") this.compressionMode = "off";
54671
+ else if (raw === "gzip") this.compressionMode = "gzip";
54672
+ else this.compressionMode = "brotli";
54673
+ }
53998
54674
  try {
53999
54675
  this.imageHandler = createImageHandler({
54000
54676
  rootDir: this.options.rootDir,
@@ -54104,31 +54780,72 @@ var init_tw6 = __esm({
54104
54780
  if ((0, import_node_fs11.existsSync)(middlewarePath)) {
54105
54781
  this.middlewarePath = middlewarePath;
54106
54782
  console.log(" Middleware: middleware.twm loaded");
54783
+ try {
54784
+ const src = (0, import_node_fs11.readFileSync)(middlewarePath, "utf-8");
54785
+ const rules = parseRules(src, "middleware.twm");
54786
+ for (const r of rules) {
54787
+ const conds = [];
54788
+ if (r.userAgent) conds.push("user_agent");
54789
+ if (r.path) conds.push("path");
54790
+ if (r.auth) conds.push("auth");
54791
+ if (r.rateLimit) conds.push("rate_limit");
54792
+ if (r.origin) conds.push("origin");
54793
+ if (r.methods && r.methods.length > 0) conds.push("methods");
54794
+ const label = conds.length > 0 ? conds.join(" + ") : "no conditions -> every matching request gets this response";
54795
+ console.log(` \u2022 "${r.name}" [${label}]`);
54796
+ }
54797
+ } catch {
54798
+ }
54107
54799
  }
54108
54800
  return this;
54109
54801
  }
54110
54802
  async start() {
54111
54803
  if (this.isRunning) return;
54112
- const { port, host } = this.options;
54113
- this.server = (typeof Bun !== "undefined" ? Bun : null)?.serve({
54114
- port,
54115
- hostname: host,
54116
- fetch: async (request) => {
54117
- const res = await this.handleRequest(request);
54118
- if (this.securityHeaders) {
54119
- try {
54120
- return this.securityHeaders.apply(res);
54121
- } catch {
54122
- return res;
54804
+ const host = this.options.host ?? "0.0.0.0";
54805
+ const wanted = this.options.port ?? 8e3;
54806
+ let port = wanted;
54807
+ let bound = null;
54808
+ let lastErr = null;
54809
+ for (let p = wanted; p < wanted + 10; p++) {
54810
+ try {
54811
+ bound = (typeof Bun !== "undefined" ? Bun : null)?.serve({
54812
+ port: p,
54813
+ hostname: host,
54814
+ fetch: async (request) => {
54815
+ const res = await this.handleRequest(request);
54816
+ let withHeaders = res;
54817
+ if (this.securityHeaders) {
54818
+ try {
54819
+ withHeaders = this.securityHeaders.apply(res);
54820
+ } catch {
54821
+ withHeaders = res;
54822
+ }
54823
+ }
54824
+ return this.compressResponse(withHeaders, request);
54825
+ },
54826
+ error: (err3) => {
54827
+ console.error("Server error:", err3);
54828
+ return new Response("Internal Server Error", { status: 500 });
54123
54829
  }
54124
- }
54125
- return res;
54126
- },
54127
- error: (err3) => {
54128
- console.error("Server error:", err3);
54129
- return new Response("Internal Server Error", { status: 500 });
54830
+ });
54831
+ port = p;
54832
+ break;
54833
+ } catch (e) {
54834
+ lastErr = e;
54835
+ const msg = String(e?.message ?? e);
54836
+ if (!/address already in use|EADDRINUSE|Failed to start server/i.test(msg)) throw e;
54837
+ if (p < wanted + 9) console.log(` Port ${p} busy \u2014 trying ${p + 1}...`);
54130
54838
  }
54131
- });
54839
+ }
54840
+ if (!bound) {
54841
+ console.error(`
54842
+ ERROR: could not bind ${host} on ports ${wanted}\u2013${wanted + 9}.`);
54843
+ console.error(` ${lastErr?.message ?? lastErr}
54844
+ `);
54845
+ throw lastErr ?? new Error("could not start server");
54846
+ }
54847
+ this.server = bound;
54848
+ this.options.port = port;
54132
54849
  this.isRunning = true;
54133
54850
  console.log(`
54134
54851
  TW Server running at http://${host}:${port}
@@ -54142,6 +54859,10 @@ var init_tw6 = __esm({
54142
54859
  // finishes ongoing requests, then closes
54143
54860
  async gracefulShutdown(timeout = 5e3) {
54144
54861
  this.isRunning = false;
54862
+ try {
54863
+ getSignalHub().closeAll();
54864
+ } catch {
54865
+ }
54145
54866
  if (this.server) {
54146
54867
  this.server.stop?.(false);
54147
54868
  const start = Date.now();
@@ -54167,6 +54888,35 @@ var init_tw6 = __esm({
54167
54888
  * - return { headers: {...} } -> headers merged into the final response
54168
54889
  * - return null/undefined -> continue
54169
54890
  */
54891
+ /** BUG 7 (v1.0.8): real response compression. `compression` in
54892
+ * tw.config.ts was silently ignored -- the middleware was a no-op and
54893
+ * nothing on the response path ever set Content-Encoding. */
54894
+ async compressResponse(res, request) {
54895
+ if (this.compressionMode === "off") return res;
54896
+ try {
54897
+ if (res.headers.get("content-encoding")) return res;
54898
+ const ct = res.headers.get("content-type") || "";
54899
+ if (ct.includes("text/event-stream")) return res;
54900
+ if (res.__twRawStream) return res;
54901
+ const buf = new Uint8Array(await res.arrayBuffer());
54902
+ if (!isLargeEnough(buf.length)) return new Response(buf, { status: res.status, headers: res.headers });
54903
+ const accept = request.headers.get("accept-encoding") || "";
54904
+ let enc = negotiateEncoding(accept);
54905
+ if (this.compressionMode === "gzip" && enc === "brotli") enc = "gzip";
54906
+ if (!enc) return new Response(buf, { status: res.status, headers: res.headers });
54907
+ const out = await compress(buf, enc);
54908
+ if (out.length >= buf.length) return new Response(buf, { status: res.status, headers: res.headers });
54909
+ const h = new Headers(res.headers);
54910
+ h.set("Content-Encoding", enc === "brotli" ? "br" : enc);
54911
+ h.set("Vary", "Accept-Encoding");
54912
+ h.delete("Content-Length");
54913
+ return new Response(out, { status: res.status, headers: h });
54914
+ } catch {
54915
+ return res;
54916
+ }
54917
+ }
54918
+ /** v1.0.8 round 5 (BUG 39): public now -- serverless adapters (Vercel
54919
+ * functions, edge runtimes) drive the server through this entry. */
54170
54920
  async handleRequest(request) {
54171
54921
  try {
54172
54922
  const u = new URL(request.url);
@@ -54339,69 +55089,12 @@ var init_tw6 = __esm({
54339
55089
  * `data: {"v":1,"seq":N,"updates":[...]}` frames.
54340
55090
  */
54341
55091
  handleSignalStream(request, url) {
54342
- const hub2 = getSignalHub();
54343
- const names = (url.searchParams.get("s") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
54344
- const since = Number(url.searchParams.get("since") ?? "0") || 0;
54345
- const declared = /* @__PURE__ */ new Map();
54346
- for (const n of names) declared.set(n, "public");
54347
- let session;
54348
- try {
54349
- const header = request.headers.get("cookie") ?? "";
54350
- const m = /(?:^|;\s*)tw_session=([^;]+)/.exec(header);
54351
- if (m) session = decodeURIComponent(m[1].trim().replace(/^"|"$/g, ""));
54352
- } catch {
54353
- }
54354
- const enc = new TextEncoder();
54355
- const sse = (payload) => enc.encode("data: " + payload + "\n\n");
54356
- const stream = new ReadableStream({
54357
- start(controller) {
54358
- const client = hub2.register(declared, session);
54359
- client.send = (payload) => {
54360
- try {
54361
- controller.enqueue(sse(payload));
54362
- } catch {
54363
- client.close();
54364
- }
54365
- };
54366
- client.close = () => {
54367
- try {
54368
- controller.close();
54369
- } catch {
54370
- }
54371
- hub2["clients"];
54372
- };
54373
- for (const payload of hub2.connectPayloads(since, declared, session)) {
54374
- try {
54375
- controller.enqueue(sse(payload));
54376
- } catch {
54377
- break;
54378
- }
54379
- }
54380
- const keepAlive = setInterval(() => {
54381
- try {
54382
- controller.enqueue(enc.encode(": keep-alive\n\n"));
54383
- } catch {
54384
- clearInterval(keepAlive);
54385
- }
54386
- }, 25e3);
54387
- client.close = () => {
54388
- clearInterval(keepAlive);
54389
- try {
54390
- controller.close();
54391
- } catch {
54392
- }
54393
- };
54394
- }
54395
- });
54396
- return new Response(stream, {
54397
- status: 200,
54398
- headers: {
54399
- "Content-Type": "text/event-stream; charset=utf-8",
54400
- "Cache-Control": "no-store",
54401
- "X-Accel-Buffering": "no",
54402
- Connection: "keep-alive"
54403
- }
54404
- });
55092
+ const cfg = this.options.config?.signalStream;
55093
+ return createSignalStream(request, url, cfg ? { maxClients: cfg.maxClients, maxHistory: cfg.maxHistory } : void 0);
55094
+ }
55095
+ async handleClientSignalWrite(request) {
55096
+ const cfg = this.options.config?.signalStream;
55097
+ return writeSignalFromClient(request, { clientWrites: cfg?.clientWrites });
54405
55098
  }
54406
55099
  /**
54407
55100
  * PPR hole fill: render the page fresh and return only the requested
@@ -54475,15 +55168,33 @@ var init_tw6 = __esm({
54475
55168
  controller.close();
54476
55169
  }
54477
55170
  });
54478
- return new Response(stream, {
55171
+ const streamRes = new Response(stream, {
54479
55172
  status: 200,
54480
55173
  headers: { "Content-Type": "text/html; charset=utf-8" }
54481
55174
  });
55175
+ streamRes.__twRawStream = true;
55176
+ return streamRes;
54482
55177
  }
54483
55178
  async handleRequestInner(request) {
54484
55179
  const url = new URL(request.url);
54485
55180
  const method = request.method;
54486
- const pathname = normalizePathname(url.pathname);
55181
+ let rawPath = url.pathname;
55182
+ try {
55183
+ const decoded = decodeURIComponent(rawPath);
55184
+ if (decoded !== rawPath) rawPath = decoded;
55185
+ } catch {
55186
+ }
55187
+ let canonical = normalizePathname(rawPath);
55188
+ if (canonical.length > 1 && canonical.endsWith("/")) {
55189
+ canonical = canonical.slice(0, -1);
55190
+ }
55191
+ if (canonical !== rawPath) {
55192
+ return new Response(null, {
55193
+ status: 308,
55194
+ headers: { Location: encodeURI(canonical) + url.search }
55195
+ });
55196
+ }
55197
+ const pathname = canonical;
54487
55198
  if (pathname === "/_tw/img" || pathname.startsWith("/_tw/img/")) {
54488
55199
  const imgRes = await this.imageHandler.handle(request);
54489
55200
  if (imgRes) return imgRes;
@@ -54491,6 +55202,9 @@ var init_tw6 = __esm({
54491
55202
  if (pathname === "/_tw/stream") {
54492
55203
  return this.handleSignalStream(request, url);
54493
55204
  }
55205
+ if (pathname === "/_tw/signal" && method === "POST") {
55206
+ return this.handleClientSignalWrite(request);
55207
+ }
54494
55208
  if (pathname === "/_tw/ppr") {
54495
55209
  return this.handlePPRHoleFill(url);
54496
55210
  }
@@ -54655,11 +55369,37 @@ __export(twm_loader_exports, {
54655
55369
  loadTWMModule: () => loadTWMModule,
54656
55370
  revalidateRoute: () => revalidateRoute,
54657
55371
  shouldMatchMiddleware: () => shouldMatchMiddleware,
54658
- stripCommentsSafe: () => stripCommentsSafe
55372
+ stripCommentsSafe: () => stripCommentsSafe,
55373
+ validateTWMSyntax: () => validateTWMSyntax
54659
55374
  });
54660
55375
  function clearTWMCache() {
54661
55376
  moduleCache.clear();
54662
55377
  }
55378
+ function validateTWMSyntax(filePath) {
55379
+ let source;
55380
+ try {
55381
+ source = (0, import_node_fs12.readFileSync)(filePath, "utf-8");
55382
+ } catch (err3) {
55383
+ return "cannot read: " + (err3?.message ?? err3);
55384
+ }
55385
+ if (isRuleDsl(source)) {
55386
+ try {
55387
+ parseRules(source, filePath);
55388
+ return null;
55389
+ } catch (err3) {
55390
+ return "rule DSL: " + (err3?.message ?? String(err3));
55391
+ }
55392
+ }
55393
+ try {
55394
+ const imports = extractImports(source);
55395
+ const jsCode = compileTwm(source);
55396
+ const codeWithoutImports = stripImports(jsCode);
55397
+ new Function("__tw_unused", codeWithoutImports);
55398
+ return null;
55399
+ } catch (err3) {
55400
+ return (err3?.message ?? String(err3)).replace(/\n/g, " ").slice(0, 200);
55401
+ }
55402
+ }
54663
55403
  function stripCommentsSafe(src) {
54664
55404
  let out = "";
54665
55405
  let i = 0;
@@ -54897,7 +55637,7 @@ async function loadTWMModule(filePath, rootDir) {
54897
55637
  console.error("[tw] " + e.message + " (" + filePath + ":" + cm[1] + ")");
54898
55638
  }
54899
55639
  }
54900
- const moduleCode = codeWithoutImports + "\nreturn { get: typeof get !== 'undefined' ? get : null, post: typeof post !== 'undefined' ? post : null, put: typeof put !== 'undefined' ? put : null, patch: typeof patch !== 'undefined' ? patch : null, deleteFn: typeof deleteFn !== 'undefined' ? deleteFn : null, GET: typeof GET !== 'undefined' ? GET : null, POST: typeof POST !== 'undefined' ? POST : null, PUT: typeof PUT !== 'undefined' ? PUT : null, PATCH: typeof PATCH !== 'undefined' ? PATCH : null, DELETE: typeof DELETE !== 'undefined' ? DELETE : null, middleware: typeof middleware !== 'undefined' ? middleware : null, generate: typeof generate !== 'undefined' ? generate : null, config: typeof config !== 'undefined' ? config : null, action: typeof action !== 'undefined' ? action : null, revalidate: typeof revalidate !== 'undefined' ? revalidate : null, " + [...actionNames].map((n) => `${n}: typeof ${n} !== 'undefined' ? ${n} : null`).join(", ") + " };";
55640
+ const moduleCode = codeWithoutImports + "\nreturn { get: typeof get !== 'undefined' ? get : null, post: typeof post !== 'undefined' ? post : null, put: typeof put !== 'undefined' ? put : null, patch: typeof patch !== 'undefined' ? patch : null, deleteFn: typeof deleteFn !== 'undefined' ? deleteFn : null, GET: typeof GET !== 'undefined' ? GET : null, POST: typeof POST !== 'undefined' ? POST : null, PUT: typeof PUT !== 'undefined' ? PUT : null, PATCH: typeof PATCH !== 'undefined' ? PATCH : null, DELETE: typeof DELETE !== 'undefined' ? DELETE : null, options: typeof options !== 'undefined' ? options : null, OPTIONS: typeof OPTIONS !== 'undefined' ? OPTIONS : null, middleware: typeof middleware !== 'undefined' ? middleware : null, generate: typeof generate !== 'undefined' ? generate : null, config: typeof config !== 'undefined' ? config : null, action: typeof action !== 'undefined' ? action : null, revalidate: typeof revalidate !== 'undefined' ? revalidate : null, " + [...actionNames].map((n) => `${n}: typeof ${n} !== 'undefined' ? ${n} : null`).join(", ") + " };";
54901
55641
  const injectedKeys = Object.keys(injected);
54902
55642
  let module2 = {};
54903
55643
  try {
@@ -54912,6 +55652,8 @@ async function loadTWMModule(filePath, rootDir) {
54912
55652
  put: H.put || H.PUT || void 0,
54913
55653
  delete: H.deleteFn || H.DELETE || void 0,
54914
55654
  patch: H.patch || H.PATCH || void 0,
55655
+ // v1.0.8 round 4 (BUG 33): route-level OPTIONS handlers
55656
+ options: H.options || H.OPTIONS || void 0,
54915
55657
  middleware: H.middleware || void 0,
54916
55658
  generate: H.generate || void 0,
54917
55659
  config: H.config || void 0,
@@ -55047,9 +55789,22 @@ async function executeRouteHandler(filePath, method, request, rootDir) {
55047
55789
  if (rawBody2.length > MAX_BODY_BYTES) {
55048
55790
  return { status: 413, json: { ok: false, error: "Payload too large" } };
55049
55791
  }
55050
- parsedBody = rawBody2 ? JSON.parse(rawBody2) : {};
55792
+ if (!rawBody2) {
55793
+ parsedBody = {};
55794
+ } else if (__ct.includes("application/x-www-form-urlencoded")) {
55795
+ parsedBody = Object.fromEntries(new URLSearchParams(rawBody2).entries());
55796
+ } else if (__ct.startsWith("text/")) {
55797
+ parsedBody = rawBody2;
55798
+ } else if (__ct.includes("json") || __ct === "") {
55799
+ try {
55800
+ parsedBody = JSON.parse(rawBody2);
55801
+ } catch (e) {
55802
+ return { status: 400, json: { ok: false, error: "Invalid JSON body: " + (e?.message ?? "parse error") } };
55803
+ }
55804
+ } else {
55805
+ return { status: 415, json: { ok: false, error: "Unsupported Content-Type: " + (__ct || "(none)") } };
55806
+ }
55051
55807
  } catch {
55052
- parsedBody = {};
55053
55808
  }
55054
55809
  } else if (request?.body && typeof request.body === "object") {
55055
55810
  parsedBody = request.body;
@@ -55057,7 +55812,18 @@ async function executeRouteHandler(filePath, method, request, rootDir) {
55057
55812
  let query2 = {};
55058
55813
  try {
55059
55814
  const u = new URL(request.url);
55060
- query2 = Object.fromEntries(u.searchParams.entries());
55815
+ for (const [rawKey, value] of u.searchParams.entries()) {
55816
+ const isPhpArray = rawKey.endsWith("[]");
55817
+ const key = isPhpArray ? rawKey.slice(0, -2) : rawKey;
55818
+ const existing = query2[key];
55819
+ if (existing === void 0) {
55820
+ query2[key] = isPhpArray ? [value] : value;
55821
+ } else if (Array.isArray(existing)) {
55822
+ existing.push(value);
55823
+ } else {
55824
+ query2[key] = [existing, value];
55825
+ }
55826
+ }
55061
55827
  } catch {
55062
55828
  }
55063
55829
  const headersObj = typeof request.headers?.entries === "function" ? Object.fromEntries(request.headers.entries()) : request.headers ?? {};
@@ -55186,9 +55952,17 @@ async function executeRouteHandler(filePath, method, request, rootDir) {
55186
55952
  console.error("[tw] route handler error (" + filePath + "):", err3?.message ?? err3);
55187
55953
  return { status: 500, json: { ok: false, error: "Internal Server Error" } };
55188
55954
  }
55189
- if (!result || typeof result !== "object") {
55190
- console.error("[tw] route handler returned no result (" + filePath + ")");
55191
- return { status: 500, json: { ok: false, error: "Internal Server Error" } };
55955
+ if (typeof result === "string") {
55956
+ result = { status: 200, text: result };
55957
+ } else if (!result || typeof result !== "object") {
55958
+ console.error("[tw] route handler returned " + JSON.stringify(result) + " (" + filePath + ")");
55959
+ return {
55960
+ status: 500,
55961
+ json: {
55962
+ ok: false,
55963
+ error: "Unsupported handler return shape: " + (result === null ? "null" : typeof result) + ". Return { status, json|text|html } (or a plain string for text/plain)."
55964
+ }
55965
+ };
55192
55966
  }
55193
55967
  const finalResult = {
55194
55968
  status: typeof result.status === "number" ? result.status : 200,