tw-framework 1.0.4 → 1.0.5

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.
@@ -124,6 +124,7 @@ const server = createServer((req, res) => {
124
124
  }
125
125
 
126
126
  const file = resolveFile(pathname);
127
+ const spaFallback = file === null;
127
128
  const finalPath = file ?? ((existsSync(join(ROOT, "index.html")) && pathname !== "/") ? join(ROOT, "index.html") : null);
128
129
  if (!finalPath) {
129
130
  // Custom 404 page if the build produced one.
@@ -136,6 +137,17 @@ const server = createServer((req, res) => {
136
137
  res.writeHead(404, { "content-type": "text/plain", ...SECURITY_HEADERS });
137
138
  return res.end("Not Found");
138
139
  }
140
+ // Unknown routes served through the SPA fallback must answer 404 -- a
141
+ // 200 soft-404 hides broken links from crawlers and uptime checks. The
142
+ // built 404.html takes priority; otherwise the shell is served WITH the
143
+ // 404 status so client-side routing still works.
144
+ if (spaFallback) {
145
+ const notFoundPage = join(ROOT, "404.html");
146
+ const spaPath = existsSync(notFoundPage) ? notFoundPage : finalPath;
147
+ res.writeHead(404, { "content-type": "text/html; charset=utf-8", ...SECURITY_HEADERS });
148
+ if (req.method === "HEAD") return res.end();
149
+ return createReadStream(spaPath).pipe(res);
150
+ }
139
151
 
140
152
  const stat = statSync(finalPath);
141
153
  const ext = extname(finalPath).toLowerCase();
@@ -269,7 +269,24 @@
269
269
  }
270
270
 
271
271
  function navigate(path, push) {
272
- fetch(path, { headers: { "X-TW-Navigate": "1" } })
272
+ // loading.tw skeleton (docs/commands-reference.md): the boundary ships
273
+ // hidden; show it while the navigation fetch runs, hide it again after.
274
+ var lb = document.querySelector("[data-loading-boundary]");
275
+ function hideSkeleton() {
276
+ if (lb) try { lb.style.display = "none"; } catch (e) { /* gone */ }
277
+ }
278
+ if (lb) {
279
+ // Show via the framework's own display style (empty string would
280
+ // fall back to the default block and fight the inline display:none).
281
+ try { lb.style.display = "block"; } catch (e) { /* ignore */ }
282
+ }
283
+ fetch(path, {
284
+ headers: { "X-TW-Navigate": "1" },
285
+ // Hover-prefetch warms the HTTP cache WITHOUT the navigate header;
286
+ // a real navigation must bypass that cache so the server can render
287
+ // `(.)page.tw` interceptors (and other nav-specific output).
288
+ cache: "reload",
289
+ })
273
290
  .then(function (r) { return r.text(); })
274
291
  .then(function (html) {
275
292
  swapPage(html, path);
@@ -277,8 +294,9 @@
277
294
  try { history.pushState({ tw: 1 }, "", path); } catch (e) { /* ignore */ }
278
295
  }
279
296
  window.scrollTo(0, 0);
297
+ hideSkeleton();
280
298
  })
281
- .catch(function () { window.location.href = path; });
299
+ .catch(function () { hideSkeleton(); window.location.href = path; });
282
300
  }
283
301
 
284
302
  document.addEventListener("click", function (e) {
@@ -72,6 +72,48 @@ if (typeof globalThis.Bun === "undefined") {
72
72
  this.params = {};
73
73
  }
74
74
  async json() { return JSON.parse(this._body ? this._body.toString("utf-8") : "{}"); }
75
+ /**
76
+ * multipart/form-data (docs/multipart-forms.md): parse the buffered body
77
+ * into a FormData-like object. Route handlers expect request.formData()
78
+ * to exist exactly like the fetch standard on Bun.
79
+ */
80
+ async formData(): Promise<any> {
81
+ const bodyStr = this._body ? this._body.toString("binary") : "";
82
+ const ct = String(this.headers?.get?.("content-type") ?? "");
83
+ const bm = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(ct);
84
+ if (!bm) throw new Error("FormData: no multipart boundary");
85
+ const boundary = "--" + (bm[1] ?? bm[2]).trim();
86
+ const out: [string, any][] = [];
87
+ const parts = bodyStr.split(boundary);
88
+ for (let i = 1; i < parts.length - 1; i++) {
89
+ const raw = parts[i];
90
+ const sep = raw.indexOf("\r\n\r\n");
91
+ if (sep < 0) continue;
92
+ const head = raw.slice(0, sep);
93
+ let data = raw.slice(sep + 4);
94
+ if (data.endsWith("\r\n")) data = data.slice(0, -2);
95
+ const nameM = /name="([^"]*)"/.exec(head);
96
+ if (!nameM) continue;
97
+ const fileM = /filename="([^"]*)"/.exec(head);
98
+ const typeM = /content-type:\s*([^\r\n]+)/i.exec(head);
99
+ if (fileM) {
100
+ const buf = Buffer.from(data, "binary");
101
+ out.push([nameM[1], {
102
+ name: fileM[1],
103
+ type: typeM ? typeM[1].trim() : "application/octet-stream",
104
+ size: buf.length,
105
+ arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
106
+ }]);
107
+ } else {
108
+ out.push([nameM[1], data]);
109
+ }
110
+ }
111
+ return {
112
+ entries: () => out[Symbol.iterator](),
113
+ forEach: (fn: any) => out.forEach(([k, v]) => fn(v, k)),
114
+ get: (k: string) => (out.find(([kk]) => kk === k) || [, undefined])[1],
115
+ };
116
+ }
75
117
  async text() { return this._body ? this._body.toString("utf-8") : ""; }
76
118
  async arrayBuffer() {
77
119
  if (!this._body) return new ArrayBuffer(0);
@@ -98,6 +140,22 @@ if (typeof globalThis.Bun === "undefined") {
98
140
  const b: any = this._body;
99
141
  // Blob (Bun.file) and other web bodies
100
142
  if (b && typeof b.arrayBuffer === "function") return await b.arrayBuffer();
143
+ // Web ReadableStream (render stream responses): drain into a buffer.
144
+ if (b && typeof b.getReader === "function") {
145
+ const reader = b.getReader();
146
+ const chunks: Uint8Array[] = [];
147
+ let total = 0;
148
+ for (;;) {
149
+ const { done, value } = await reader.read();
150
+ if (done) break;
151
+ chunks.push(value);
152
+ total += value.byteLength;
153
+ }
154
+ const out = new Uint8Array(total);
155
+ let off = 0;
156
+ for (const c of chunks) { out.set(c, off); off += c.byteLength; }
157
+ return out.buffer;
158
+ }
101
159
  const buf = Buffer.isBuffer(b) ? b : Buffer.from(b);
102
160
  return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
103
161
  }
@@ -137,11 +195,31 @@ if (typeof globalThis.Bun === "undefined") {
137
195
  body: req.method === "GET" || req.method === "HEAD" ? undefined : body,
138
196
  });
139
197
  const response = await opts.fetch(request);
140
- const buf = Buffer.from(await response.arrayBuffer());
141
198
  const outHeaders = {};
142
199
  for (const [k, v] of response.headers.entries()) outHeaders[k] = v;
143
- res.writeHead(response.status, outHeaders);
144
- res.end(buf);
200
+ // Stream ReadableStream bodies chunk-by-chunk. Buffering via
201
+ // arrayBuffer() would HANG forever for persistent streams
202
+ // (signal streaming SSE /_tw/stream never closes).
203
+ const respBody: any = (response as any).body;
204
+ if (respBody && typeof respBody.getReader === "function") {
205
+ res.writeHead(response.status, outHeaders);
206
+ const reader = respBody.getReader();
207
+ const pump = (): void => {
208
+ reader
209
+ .read()
210
+ .then(({ done, value }: { done: boolean; value?: Uint8Array }) => {
211
+ if (done) { try { res.end(); } catch { /* closed */ } return; }
212
+ try { res.write(Buffer.from(value ?? [])); } catch { /* client gone */ }
213
+ pump();
214
+ })
215
+ .catch(() => { try { res.end(); } catch { /* closed */ } });
216
+ };
217
+ pump();
218
+ } else {
219
+ const buf = Buffer.from(await response.arrayBuffer());
220
+ res.writeHead(response.status, outHeaders);
221
+ res.end(buf);
222
+ }
145
223
  } catch (err) {
146
224
  try { res.writeHead(500, { "Content-Type": "text/plain" }); res.end("Internal Server Error"); } catch { /* closed */ }
147
225
  console.error("[tw:node] request error:", err && err.message);